From 2560bc537feb539a202e8b2edfb1eb06d6968a63 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Tue, 27 Oct 2026 12:26:18 +0000
Subject: [PATCH 1/4] fix(docx): anchor a drawing in a paragraph of the block
it belongs to
A drawing stood in the paragraph whose text was nearest it, within 48pt and
rising above it by 24pt at most, by a stroke's width in a table cell, and
otherwise was placed from the page's edges, where it stayed while the text
above it was edited: an icon over a card's corner, a sidebar's portrait, a
skill's meter in a row, a timeline's dots in a column of their own.
A drawing a node paints is now anchored in the first paragraph able to
carry it that the nearest block holding it writes on its page, the page's
flow left out (DocxLayoutMetrics.holdersOf), and placed from that
paragraph's top however far off it stands. In a table cell the cell of
that paragraph's row holding it across takes it, else the paragraph's own
cell keeps it out of its column, unless it holds text, which LibreOffice
26.8 pulls back into the column. A cell set to the middle or the bottom
carries none: LibreOffice 24.2 sets such a cell's text from its top once it
carries a drawing. A drawing no holder carries goes to the paragraph
nearest it on its page. What a table's composed cell draws keeps the
nearest-text rule.
The report's notes say how each drawing is anchored; the recipe, the
capability matrix and the CHANGELOG describe the rule; the Word export
companion preview is re-rendered, its badge now in the paragraph above it.
---
CHANGELOG.md | 31 ++
.../examples/word-export-companion.docx | Bin 10247 -> 10268 bytes
.../architecture/backend-capability-matrix.md | 4 +-
docs/recipes/docx-export.md | 85 ++--
.../semantic/docx/DocxDrawingAnchors.java | 231 +++++++--
.../semantic/docx/DocxLayoutMetrics.java | 30 ++
.../semantic/docx/DocxSemanticBackend.java | 28 +-
.../semantic/docx/DocxCellDrawingTest.java | 17 +-
.../semantic/docx/DocxDrawingAnchorsTest.java | 90 ++--
.../semantic/docx/DocxDrawingHolderTest.java | 175 +++++++
.../docx/DocxDrawingOwnerAnchorsTest.java | 457 ++++++++++++++++++
.../semantic/docx/DocxDrawingsTest.java | 27 +-
12 files changed, 1041 insertions(+), 134 deletions(-)
create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingHolderTest.java
create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingOwnerAnchorsTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5746c8590..15197e437 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,37 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A DOCX export anchors a drawing in a paragraph of the block it belongs to, so it moves with
+ that block when the text above is edited.**
+ - A shape stood in the paragraph whose text was nearest it, within 48pt, rising above that
+ paragraph's top by 24pt at most — in a table cell by a stroke's width —, and otherwise was
+ placed from the page's edges, where it stayed while the text moved away from it: an icon laid
+ over a card's corner, a sidebar's portrait, a skill's meter beside its label in a row, a
+ timeline's dots in a column of their own.
+ - A drawing a node paints is now anchored in the first paragraph able to carry it that the
+ nearest block holding it writes on its page — the node itself, a layer stack, a panel, a row,
+ but not the page's flow —, placed from that paragraph's top however far from its text it
+ stands. In a table cell, the cell of that paragraph's row holding the drawing across takes it,
+ its own where it stands in a column of its own; another cell keeps it placed out of its
+ column, unless it holds text — a text box, a badge's letters —, which LibreOffice 26.8 pulls
+ back into the column. A cell set to the middle or the bottom carries none: the block's next
+ paragraph able to place it takes the drawing, or else the next block up. A drawing no node
+ paints — a timeline's rail — and one whose blocks write no such paragraph on its page go to
+ the paragraph nearest it there. What a table's composed cell draws keeps the nearest-text
+ rule. The report's note on each drawing says how it is anchored.
+ - Measured in Word 16.0.20527 and LibreOffice 24.2: a shape anchored in a table cell's
+ paragraph stands where both say — rising 36pt above its row, hanging below its table, out of
+ its cell, taller than its row, in an exact row or a nested table, in front of the text or
+ behind it, and across the fidelity corpus from 155pt above its paragraph to 620pt below —;
+ neither clips it nor moves a line for it, and both move it with its row when the text above
+ is edited. LibreOffice 24.2 sets a cell carrying a drawing from its top whatever its vertical
+ alignment says, 16pt high in a cell set to the middle and 32pt in one set to the bottom.
+ - Of the 465 drawings the fidelity corpus placed on the page, 270 were drawn by a node, 5 were
+ timelines' rails and 190 were composed in table cells. All but 14 of the first 275 are now
+ anchored in a paragraph: the text boxes of `LumaStudio`'s sidebar and closing note, which no
+ paragraph of their column holds across, and two drawings on pages whose only text is a pair
+ of lines.
+
- **A DOCX export writes a painted box holding text in the body as a panel, its text inside its
outline.**
- A shape container shaped as a rectangle or a rounded rectangle, filled or outlined, holding
diff --git a/assets/readme/examples/word-export-companion.docx b/assets/readme/examples/word-export-companion.docx
index 808a3ef6ffbd321c946a580a758636f778c774c9..f804b8074dd6bce66a45b5022b31f4c80d11ae3d 100644
GIT binary patch
delta 3616
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
delta 3556
zcmZ9Pc{tQv8^>piWh{fiNGa$u?4i?6SN)p67Y5=Y7vV_xF77>pthcu5gTf
zY}+AsSLM_DZL|07irl}E8i&A$q6n$7tuNE*AtrhJl-~9G?mf>nd*d}_dT%zA@$24y
zaI$t6l^@;hb|mY2!EVqjaAyh1s|FjwEG^4Fb13_WG!!mBDnQ;;Ech@KKml(~FjdX=
zpR(%x6e8#1JZ;x68TV+RS2}>Uv+1M9-2KPhnK
zykTM4wcdNV?Ie8=RQ5=dFuxHu``*v*nn?p`ep)s|Ra+-12%XgM7^S1NGQh#GhNop%
z^ZsgnSf*W!Y(@t%1!{|&@))g3ECGzGHBA|vc$`*JOOO$s=ewL_MSCwyB(BKM%8F=%
z+EjmfjCj0EIAtPzG&4|GU9`JKf`Q`PM0eCnSvpUI;gv+}K1rOvPl6h8M+v;Mu&8?2
z3*n!jELi|+4Ldw&&M!XhL5RWVkPMw-J5k*6SMhQlR(-#lGacD;1G!+21GO^nqlVZn
zSf*A`j6`Y30KBs>TsP4zCaih1`Eg)p{iCOO4`j2l75XQ7de!EP>S_3Pe&!(D(rkU|
z`_sqC)0_!dN$Acr?Lh0=IpE{~u||LzUBF>ek)mV*qh-6C6)7;`zPb3s{$pwUn64*T
z+VYOg2%vlFeb#qv1(#p(K(JyAfkXD5nbws}p~kcTF=&2j&pP=O!d+iH@>LESnwSGF
ztLBbvS7YrL3+b2Ssf=5X-aWw<6;{7#{b+#?(QW`efU}l&K!_i(aKp{eIpMPxrJeH>
z{xERwDm>1M^y3ijdHiHXqS0hnUOOZey4L+-|5u!Ok#6~*oVm;r0CQIvg1Nq@o|Pc>`LHj
zz05u+qmS;MU1yV%wdVOr=>u*8wAHEskEY))zN(+bQ{@t-zB0O@wbYOLa!aSoM%Z*F
z!~g3Ke}?sBUiZB@(j58d`8!jP4$jJZBknK*>28lvED-}->`_L!$jtWX==))7KFY45
z(-xJJ7Q}-Q#J2MW&nm|KFkK2iMr!!nwWsbD=Tkb^7oFX!A#2)d*1nBx}VKxC#
zU)ewZ{>?cuf!gL1nZSyEUGEO@8WutK{Fi;tC_QP6)b3}IvLpg
zfvqS!#ixEEwE2?1hHY9!;Du}j7hmrw#EwK)0|MX_i)`>vDuz4LlxWZAZ79-FFRr(6
zzhro&XZo=sZ4tK;P-FYmf!glkS$n$kJHex`an@-^y08d2cbtxDUscx&WvL3F8+rD-
z9bMe1XQe+GKC{X15JU_jhGB^N=Sq>8+$=X(=JT0`Li=K$zHxgRx51Agta@EM?HZIA
zs|UO!#0pQJJt-`Ab5n72-=&cN&XUuU_w(mprE@RWW}dNt4bSr2QGQ6E3Wwjem%DcSXPUU{fvId
zjEoO_S|0z?_3F%ZC{)ULN+&HQ*C&V6JDdrgYx@@1^SSU1m#uq)3WOPp6T6vq@0Ivg
z{nwvxy4-23+%QgX8V=U&fFQJ`ymL(TBcV7c_}Z0^J~QJH);4D!{Bq+8i*RPc#bOdg
zinYUrE>%mfi<&m~VQ)zTCE3IVy^Hczd)_K(2T_rC5g1(NU8i#OkEQ)^m~sSTcPrrx
zmKhzd!zll>>|3ozcy*w>G;ASQaVrkuwZ^JyRNQFos5H3-HAll{qInCL%PC53?B-mh
z>Bhe-vL)`bPM9Q^f6E?oG4HX>*s!MJx+AL@BpxYty^ed%gJuPso5Z#|jQ1#V&uf2i
ziDhlL^PxSY30wDL9h%yGUp5XV5_4WTJWBPb-(-0fCDt6+c15BjKulS-53gxS^HSBp
zjrZR5ETb|ES@H!Z(9uVJ#)gxMd0LMz7hEQgZ?y-IE>jl-m^CS9?VMPT^ad+qoW`b4pf}-mGFB!BU0mMCrEHWk-UxF{F=Cu;;;-@=uB-Ym*iOpR4(k
z%6C+|(5GBkQEdW^O&+h=HauT9vK}MD=M^8~=ouC0Q@u^!%6UV=^=g@tCm{-@1|M7^
zss!3@i@QF{B8B^qI4_BEn}rv}y`3;+P4=r`Xi&bg9m)nQqZ+(@I5xjNI9+3;{t$ttg(BoaHqO)qW)^^vh
z(odq^(;eL7uNxe(;~KYB(0#lu!Rdb(^S5|2SuS)Xz8Au?g|=vVE;y|xcOb$oiY39a
zA;#;3%RjgQcB?;X-)_LTb+KA1*{bp_vW3@O8<~V*>hKG9cK=AKTVWe1WGt6|pO6*r
z#KotN4^Qa{3NUh8=d0-C+jZg_RDEYRN|73r>TxbM55Q!2nzFQLbZvh2mZBKhZ;j3S
z$|M+()d-d3gCu{NkGi1d-0Y7#Mv>uZ>QJtN{>-BQ%HgyKSaLydElXxLj)wZO8}YEg
zq}4EYdu~pShQD5$HB0%}<}qrmOH7}!DHU&6nO(lzZf{Z?&;C>PRxB!eu$89ldo0Uk
zmCm(R+9o(UD6Y@1n?8NgGJP`Wtl|$q#Y*itm-?sgJG`3J
zn@wkc3_hM(w&Hvm*`ZqNLqzJxwVU@aVqG~qAnxeFN=+l|BomL9f4WV3`@(H#g#0eV
zQ|xt|zhSh!uefxK3I^+Q#qNr&?MqPK2~SVc&9unW5~X+di>~dfJeXjWtq$=Gv!RxE
z);&OEk9h^#T$-2}cz~cZtL?gUCXGCtvZb{FCuQ>b*`dCnLh3uPOh_(d+`i!Mqk>?T
zb1bcUpxf?oO^|73c{O>=@kDa6{H+hT51+X1l9P}9t?)XL8Y1cGO>bB!D-9K(Lg
zWPMg8>e=Ok3trDHnn?F%HdU$~(H3*5$i@p%cM)$GZp#e5L2uaq7;4+3c^@4@$55+*
z27|@y<6rz3^O25bEvk2znF@3hKdORI1wxFl(%+voH4P5C|(
zFyujQAj}rtBQoNf08XXQ6F==X2Ck{awR$mK&@*y|`)6K~#!e)Gojc1LO9=Tw^RJF6
z+yu_PFaXMhBN>M_9M@K|ss1grif`jZP9s$}!XB9{8kmF=Q>D6x+r>^qAauqA>SZcp
zt8R1<-C&9;P7OC^Q9{m}n@@z5Hu=wr(!=)7{y8T5p%30Kvw=Y1u>YHqYy7y;e^E^i
zT#Wk=Z`wqk{e?Fn;#G%+9MbhKBV7V>$UE;PR1PDzqz3D6{;@k8!bujAD#Cy6{(To5
z^zS81Dwh;Ftb8LW$Z(OH1aJ$Gc?_(1=qEW4?}EACTVAW#+(1Va3~8gz%GfI=RY
z+n@v)@A shape standing beside a paragraph's text — a timeline's dot by its entry, an icon by its
- * heading, a skill's bar by its label — is anchored in that paragraph and placed down from its
- * top ({@link #seat}): Word moves it with the paragraph, wherever an edit above takes it. Placed
- * from the page's edges, it stayed where the page had put it while the text moved away from it.
- * The paragraph is the one whose text is nearest, within {@link #REACH}, when the shape rises
- * above its top by the shape's own height at most, as a dot centred on its first line does — in a
- * table cell by a stroke's width at most. In a
- * table cell, the cell that holds the shape across takes it — the paragraph's own, or another of
- * its row, as a timeline's dot in a column of its own beside its entry's text — placed from that
- * cell's first paragraph (see {@link Seat#place}). When the nearest paragraph cannot place the
- * shape, it is left to the page rather than given to one further off. The shapes wait for the
- * section's end, since the paragraph a shape stands beside may be written after it.
+ *
A shape a node draws belongs to that node and to the blocks holding it: an icon laid over a
+ * card's corner to the card's stack, a skill's bar to its row, a portrait to its sidebar. It is
+ * anchored in the first paragraph the nearest of them writes on its page that can place it, and
+ * placed from that paragraph's top ({@link #seat}), so Word moves it with that block wherever an
+ * edit above takes it; where it stands gives only its offset, however far from the paragraph's
+ * text that is. Placed from the page's edges, it stayed where the page had put it while the text
+ * moved away from it. In a table cell, the cell of the paragraph's row that holds the shape
+ * across takes it — a timeline's dot stands in a column of its own beside its entry's text —
+ * placed from that cell's first paragraph (see {@link Seat#placeHeld}). A shape no node draws —
+ * a timeline's rail — or whose holders write no paragraph on its page goes to the paragraph
+ * nearest it there. What a table's composed cell draws has no node of its own, and goes to the
+ * paragraph whose text is nearest, within {@link #REACH}, when the shape rises above its top by
+ * its own height at most — in a table cell by a stroke's width at most — or else to the page. The
+ * shapes wait for the section's end, since the paragraph carrying a shape may be written after
+ * it.
*
*
A shape beside no paragraph is positioned from the page's edges, so any paragraph on its
* page carries it to the right place — but only one on that page: an anchor takes its shape to
@@ -84,10 +87,15 @@ final class DocxDrawingAnchors {
private final Map> seats = new HashMap<>();
// The paragraphs offered as seats in the section, in the order they were written.
private final List offers = new ArrayList<>();
+ // Where Word places from in each paragraph taken as a seat in the section, once asked: a
+ // drawing's holders and the paragraphs nearest it try the same paragraphs again.
+ private final Map tops = new java.util.IdentityHashMap<>();
// Identifiers unique in the document, shared with the other drawings the export writes.
private final LongSupplier ids;
// How tall Word stands a written paragraph's line, and its baseline in it.
private final Function lines;
+ // The paths of the node at a path and of the blocks holding it, nearest first.
+ private final Function> holders;
// The places given each fragment's shapes in the section so far, by the fragment's place in
// the page's paint order.
private final Map placesTaken = new HashMap<>();
@@ -107,18 +115,18 @@ final class DocxDrawingAnchors {
*/
static final int PLACES_PER_FRAGMENT = 8;
- DocxDrawingAnchors(LongSupplier ids) {
- this(ids, DocxGrownLines.NONE::lineOf);
- }
-
/**
- * @param ids identifiers unique in the document
- * @param lines how tall Word stands a written paragraph's line, and its baseline in it
- * ({@link DocxGrownLines#lineOf})
+ * @param ids identifiers unique in the document
+ * @param lines how tall Word stands a written paragraph's line, and its baseline in it
+ * ({@link DocxGrownLines#lineOf})
+ * @param holders the paths of the node at a path and of the blocks holding it, nearest first,
+ * the page's flow left out ({@link DocxLayoutMetrics#holdersOf})
*/
- DocxDrawingAnchors(LongSupplier ids, Function lines) {
+ DocxDrawingAnchors(LongSupplier ids, Function lines,
+ Function> holders) {
this.ids = ids;
this.lines = lines;
+ this.holders = holders;
}
/** Forgets everything, for a new export. */
@@ -130,6 +138,7 @@ void reset() {
breakParagraph.clear();
seats.clear();
offers.clear();
+ tops.clear();
placesTaken.clear();
latest = -1;
drawn = 0;
@@ -226,12 +235,22 @@ List ordered(List shapes, int painted) {
/**
* What a drawing is drawn for: the report's note on it, which a drawing the section's end
- * finds nothing to carry is named on instead (see {@link Leftovers}).
+ * finds nothing to carry is named on instead (see {@link Leftovers}), and the node whose
+ * holders carry it.
*
- * @param subject the note's subject
- * @param path the note's path
+ * @param subject the note's subject
+ * @param path the note's path: the node that draws it, whose holders look for the
+ * paragraph carrying it; a path no node has — a pass of the layout's own —
+ * leaves it to the paragraph nearest it
+ * @param composed whether a table's composed cell draws it, which has no node of its own: it
+ * goes to the paragraph whose text it stands nearest, within reach
*/
- record Owner(String subject, String path) {
+ record Owner(String subject, String path, boolean composed) {
+
+ /** What a node draws, or a pass of the layout's own. */
+ Owner(String subject, String path) {
+ this(subject, path, false);
+ }
}
/**
@@ -266,16 +285,18 @@ void queueOrdered(List shapes, Owner owner) {
*
* @param page the page, counted within the section
* @param owner what the text is written for: one of its paragraphs takes the place
+ * @param path the path of the node the text is written for, which the blocks holding a
+ * drawing are matched against; {@code null} for text no node writes
* @param paragraph the paragraph holding that text
* @param left the text's left edge, from the page's left edge
* @param right the text's right edge, from the page's left edge
* @param top the top of its first line, from the page's top edge
* @param baseline its first line's baseline, from the page's top edge
*/
- void seat(int page, Object owner, XWPFParagraph paragraph, double left, double right, double top,
- double baseline) {
+ void seat(int page, Object owner, String path, XWPFParagraph paragraph, double left, double right,
+ double top, double baseline) {
if (page >= 0) {
- offers.add(new Offer(page, owner, new Seat(paragraph, left, right, top, baseline)));
+ offers.add(new Offer(page, owner, new Seat(paragraph, left, right, top, baseline, path)));
}
}
@@ -374,7 +395,9 @@ Leftovers endSection(int lastPage, Supplier opening, Supplier {
List shapes = new ArrayList<>();
for (Ordered shape : all) {
- Place place = placeBeside(shape.shape());
+ Owner owner = owners.get(shape);
+ Place place = owner != null && owner.composed() ? placeBeside(shape.shape())
+ : placeInItsHolder(shape.shape(), owner == null ? List.of() : holders.apply(owner.path()));
if (place != null) {
beside.computeIfAbsent(place.paragraph(), ignored -> new ArrayList<>()).add(new Placed(shape, place));
} else {
@@ -422,6 +445,7 @@ Leftovers endSection(int lastPage, Supplier opening, Supplier shapes, DocxDrawings.Cell
}
}
+ /**
+ * Where a shape a node draws is anchored: in the first paragraph on its page of the nearest
+ * of its holders that writes one there it can be placed from, or else in the paragraph
+ * nearest it on its page; {@code null} where no paragraph on its page can carry it.
+ *
+ *
Its holders — the node drawing it, then each block holding that node — are what it
+ * belongs to: an icon laid over a card's corner is the card's stack's, a skill's bar its
+ * row's, a portrait its sidebar's. Anchored in the first paragraph one of them writes on its
+ * page, it moves with that block wherever an edit above takes it, however far from the
+ * paragraph's text it stands: where it stands gives only its offset. A holder that writes no
+ * paragraph there, or only ones that cannot place it, passes it on to the next one up. A shape
+ * no holder carries — a timeline's rail, which no node draws, or one whose holders' text is a
+ * list or a table — goes to the paragraph nearest it.
+ *
+ * @param holders the paths of the node drawing it and of the blocks holding it, nearest first
+ */
+ private Place placeInItsHolder(DocxDrawings.Shape shape, List holders) {
+ List onItsPage = seats.getOrDefault(shape.page(), List.of());
+ for (String holder : holders) {
+ for (Seat seat : onItsPage) {
+ Place place = seat.isWithin(holder) ? placeHeld(seat, shape) : null;
+ if (place != null) {
+ return place;
+ }
+ }
+ }
+ return placeBesideTheNearest(shape, onItsPage);
+ }
+
+ /** Where a paragraph places a shape it carries for a block holding both ({@link Seat#placeHeld}). */
+ private Place placeHeld(Seat seat, DocxDrawings.Shape shape) {
+ return seat.placeHeld(shape, tops.computeIfAbsent(seat, taken -> taken.paragraphTop(lines)));
+ }
+
+ /**
+ * The paragraph on a shape's page whose text it stands nearest that can place it, however far
+ * off; {@code null} where none can.
+ */
+ private Place placeBesideTheNearest(DocxDrawings.Shape shape, List onItsPage) {
+ List byDistance = new ArrayList<>(onItsPage);
+ byDistance.sort(java.util.Comparator.comparingDouble(seat -> distance(shape, seat)));
+ for (Seat seat : byDistance) {
+ Place place = placeHeld(seat, shape);
+ if (place != null) {
+ return place;
+ }
+ }
+ return null;
+ }
+
+ /** How far a shape stands from a paragraph's text: down from its first line's top, and across past its edges. */
+ private static double distance(DocxDrawings.Shape shape, Seat seat) {
+ return Math.abs(shape.top() - seat.top())
+ + Math.max(0, seat.left() - (shape.x() + shape.width()))
+ + Math.max(0, shape.x() - seat.right());
+ }
+
/**
* The paragraph whose text a shape stands nearest, within reach, when it can hold the shape
* placed down from its top; {@code null} when there is none, or it cannot.
@@ -474,9 +555,7 @@ private Place placeBeside(DocxDrawings.Shape shape) {
Seat nearest = null;
double nearestBy = Double.POSITIVE_INFINITY;
for (Seat seat : seats.getOrDefault(shape.page(), List.of())) {
- double by = Math.abs(shape.top() - seat.top())
- + Math.max(0, seat.left() - (shape.x() + shape.width()))
- + Math.max(0, shape.x() - seat.right());
+ double by = distance(shape, seat);
if (by <= REACH && by < nearestBy) {
nearest = seat;
nearestBy = by;
@@ -541,8 +620,16 @@ private void anchorBeside(XWPFParagraph paragraph, List shapes) {
* @param right its text's right edge, from the page's left edge
* @param top its first line's top, from the page's top edge
* @param baseline its first line's baseline, from the page's top edge
+ * @param path the path of the node its text is written for; {@code null} for none
*/
- record Seat(XWPFParagraph paragraph, double left, double right, double top, double baseline) {
+ record Seat(XWPFParagraph paragraph, double left, double right, double top, double baseline, String path) {
+
+ /** Whether its text is written for the node at a path, or for a node that node holds. */
+ boolean isWithin(String holder) {
+ return path != null && (path.equals(holder)
+ || path.length() > holder.length() && path.startsWith(holder)
+ && path.charAt(holder.length()) == '/');
+ }
/**
* Where the page puts the top Word places a paragraph's shapes from: its first line's,
@@ -621,9 +708,11 @@ private org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR firstRunOfTex
}
/**
- * Where Word places a shape beside this paragraph's text from, or {@code null} where it
- * cannot. The shape may rise above the paragraph's top as {@code risesNoFurther} allows:
- * by its own height in the body, by a stroke's width in a table cell.
+ * Where Word places a shape a table's composed cell draws beside this paragraph's text
+ * from, or {@code null} where it cannot; a shape a node draws is placed by
+ * {@link #placeHeld}. The shape may rise above the paragraph's top as
+ * {@code risesNoFurther} allows: by its own height in the body, by a stroke's width in a
+ * table cell.
*
*
A paragraph of the body places it across from the page's edge. One in a table cell
* places it from the cell's text column; a cell that does not hold the shape across hands
@@ -652,6 +741,68 @@ Place place(DocxDrawings.Shape shape, Function lines)
return inTheRow(shape, cell, cellLeft, width, top);
}
+ /**
+ * Where Word places a shape this paragraph carries for a block holding both, however far
+ * from its text the shape stands; {@code null} where it cannot.
+ *
+ *
Measured in Word 16.0.20527 and LibreOffice 24.2: a shape anchored in a table cell's
+ * paragraph, placed from the cell's text column and the paragraph's top, stands where both
+ * say when it rises above its row or its table, hangs below them, stands out of its cell,
+ * is taller than its row, sits in a row of an exact height or in a nested table, in front of
+ * the text or behind it — from 36pt above its paragraph to 70pt below in probes, from 155pt
+ * above to 620pt below in the fidelity corpus. Neither editor clips it or moves a line for
+ * it, and both move it with the row when the text above it is edited. A cell set to the
+ * middle or the bottom, or held in one, carries none: LibreOffice 24.2 sets the text of a
+ * cell carrying a drawing from the cell's top, whatever drawing it is — 16pt and 32pt high
+ * in a row three lines taller than it. A paragraph of the body places a shape across from
+ * the page's edge.
+ *
+ *
A cell holding the shape across takes it in this paragraph; one that does not hands it
+ * to the cell of its row that does, the shape's own ({@link #inTheRow}), or else keeps it,
+ * placed out of its column — unless it holds text, a text box or a badge's letters, which
+ * LibreOffice 26.8 pulls back into the column. None takes it in a repeated header row,
+ * which Word repeats on every page with what is anchored in it, nor for a line not set from
+ * the cell's left, whose text column its text does not tell.
+ *
+ * @param top where Word places from in this paragraph ({@link #paragraphTop}); NaN where
+ * something above its line hides it
+ */
+ Place placeHeld(DocxDrawings.Shape shape, double top) {
+ if (Double.isNaN(top)) {
+ return null;
+ }
+ if (!(paragraph.getBody() instanceof XWPFTableCell cell)) {
+ return new Place(paragraph, Double.POSITIVE_INFINITY, top);
+ }
+ if (cell.getTableRow().isRepeatHeader() || !leftAligned(paragraph) || !setFromTheTop(cell)) {
+ return null;
+ }
+ double width = widthOf(cell);
+ double cellLeft = textLeft() - leftMargin(cell);
+ if (Double.isNaN(width) || Double.isNaN(cellLeft)) {
+ return null;
+ }
+ if (holdsAcross(shape, cellLeft, width)) {
+ return new Place(paragraph, cellLeft + leftMargin(cell), top);
+ }
+ Place own = inTheRow(shape, cell, cellLeft, width, top);
+ // LibreOffice 26.8 pulls a shape holding text back into the column of the cell it is
+ // anchored in: a text box placed 120pt left of its column stood at the column's start.
+ // A shape holding none it draws out of the column, as Word and LibreOffice 24.2 do both.
+ return own != null || shape.text() != null ? own : new Place(paragraph, cellLeft + leftMargin(cell), top);
+ }
+
+ /** Whether a cell, and each cell holding its table, sets its content from its top. */
+ private static boolean setFromTheTop(XWPFTableCell cell) {
+ for (XWPFTableCell at = cell; at != null; ) {
+ if (!alignedToTheTop(at)) {
+ return false;
+ }
+ at = at.getTableRow().getTable().getBody() instanceof XWPFTableCell holding ? holding : null;
+ }
+ return true;
+ }
+
/**
* Whether a shape rises above a paragraph's top no further than it may: in the body, by
* its own height, as a dot centred on the paragraph's first line does, and
@@ -659,6 +810,12 @@ Place place(DocxDrawings.Shape shape, Function lines)
* placed 1pt above its paragraph in a cell of a contact row moved that row's text 1.8pt up
* in the Linux LibreOffice the build checks with, though not in Word or LibreOffice on
* Windows. NaN for a top not known is no.
+ *
+ *
Only what a table's composed cell draws is held to this
+ * ({@link DocxDrawingAnchors#placeBeside}). That contact row's cells are set to the middle,
+ * and LibreOffice 24.2 sets such a cell's text from its top once it carries a drawing,
+ * wherever the drawing stands; a cell set from its top moved no line for a drawing 36pt
+ * above its paragraph ({@link #placeHeld}).
*/
private static boolean risesNoFurther(DocxDrawings.Shape shape, double top, boolean inCell) {
double rise = inCell ? SLACK : Math.max(SLACK, Math.min(shape.height(), FURTHEST_RISE));
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java
index 3a3a02a57..0be0c7504 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java
@@ -873,6 +873,36 @@ String pathOf(DocumentNode node) {
return node == null ? null : paths.get(node);
}
+ /**
+ * The paths of a node and of the blocks holding it, nearest first: where a drawing it paints
+ * looks for the paragraph that carries it ({@code DocxDrawingAnchors}).
+ *
+ *
A container the tree starts at is the page's flow, holding every block on its pages and
+ * so none in particular: it is left out. Any other node the tree starts at — a section or a
+ * stack added at the root rather than in a flow — is a block and stays, as does a container
+ * further in. A path the tree does not name — a pass of the layout's own, a timeline's rail —
+ * has no holders.
+ *
+ * @param path a node's path ({@link #pathOf})
+ * @return its holders' paths, itself first; empty for a path no node of the tree has
+ */
+ List holdersOf(String path) {
+ if (path == null || !nodesByPath().containsKey(path)) {
+ return List.of();
+ }
+ List holders = new ArrayList<>();
+ for (String holder = path; holder != null; ) {
+ int last = holder.lastIndexOf('/');
+ String above = last < 0 ? null : holder.substring(0, last);
+ if (above == null && nodesByPath().get(holder) instanceof com.demcha.compose.document.node.ContainerNode) {
+ break;
+ }
+ holders.add(holder);
+ holder = above;
+ }
+ return holders;
+ }
+
/**
* The height of one line of the paragraph's text, as the engine measured it.
*
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
index 4e7984280..a7650130d 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
@@ -206,10 +206,22 @@ public final class DocxSemanticBackend implements SemanticBackend {
* sets lines on its 0.12pt grid, and a twip of margin moves a line a step or nothing.
*/
private static final double LEAST_ROW_TEXT_MOVE = 0.1;
- /** How a drawing the page paints is anchored, as the report says it (see DocxDrawingAnchors). */
+ /**
+ * How a drawing a table's composed cell paints is anchored, as the report says it: such a cell
+ * has no node of its own to be held by (see DocxDrawingAnchors).
+ */
private static final String ANCHORED_BESIDE_ITS_TEXT = "anchored in the paragraph whose text it stands "
+ "beside, so it moves with that text when the text above is edited, or to the page where it "
+ "stands beside none";
+ /** How a drawing a node paints is anchored, as the report says it (see DocxDrawingAnchors). */
+ private static final String ANCHORED_IN_ITS_BLOCK = "anchored in the first paragraph able to carry it "
+ + "that the nearest block holding it writes on its page, so it moves with that block when the text "
+ + "above is edited; in the paragraph nearest it where no block holding it writes one there, or to "
+ + "the page where no paragraph on its page can carry it";
+ /** How a drawing no node paints — a timeline's rail — is anchored, as the report says it. */
+ private static final String ANCHORED_IN_THE_NEAREST = "anchored in the paragraph nearest it on its page, "
+ + "so it moves with that paragraph when the text above is edited, or to the page where no "
+ + "paragraph on its page can carry it";
/**
* The report's subject for a translucent colour Word holds only opaque — a cell's shading, a
* border — flattened against the colour under it (see {@link DocxTranslucency}).
@@ -452,7 +464,7 @@ public final class DocxSemanticBackend implements SemanticBackend {
private final java.util.Map cutsDecided = new java.util.IdentityHashMap<>();
// Where the shapes this export draws are anchored — see DocxDrawingAnchors.
private final DocxDrawingAnchors anchors = new DocxDrawingAnchors(() -> nextDrawingId++,
- paragraph -> grownLines.lineOf(paragraph));
+ paragraph -> grownLines.lineOf(paragraph), path -> this.layout.holdersOf(path));
// The page the node being written starts on, as the layout placed it, counted within the
// section being written.
private int currentPage;
@@ -1314,7 +1326,7 @@ private byte[] write(List sections, Path outputFile) throws Exc
drawingFor = new DocxDrawingAnchors.Owner("timeline rail", pass.path());
if (queueDrawing(pass, drawsInFront()) && railsNamed.add(pass.path())) {
report.add(DocxExportReport.Severity.APPROXIMATED, "timeline rail", pass.path(),
- "drawn as a line where the layout puts it, " + ANCHORED_BESIDE_ITS_TEXT);
+ "drawn as a line where the layout puts it, " + ANCHORED_IN_THE_NEAREST);
}
drawingFor = null;
}
@@ -5073,7 +5085,7 @@ private boolean drawAndNameOwnFragments(DocumentNode node) {
StringBuilder message = new StringBuilder(drewInCell
? "drawn as a shape anchored in the table cell it fills, where the layout puts it "
+ "in the cell: it moves with the row"
- : "drawn as a shape where the layout puts it, " + ANCHORED_BESIDE_ITS_TEXT);
+ : "drawn as a shape where the layout puts it, " + ANCHORED_IN_ITS_BLOCK);
if (badgeText != null) {
message.append("; its text is held in the shape, which stands in front of the text");
} else if (front) {
@@ -9838,8 +9850,8 @@ private void offerASeat(XWPFParagraph para) {
}
// The baseline the page draws the first line on, seated as the page seats it.
layout.firstTextBox(node).ifPresent(box ->
- anchors.seat(box.page(), node, para, box.left(), box.right(), canvasHeight - box.top(),
- canvasHeight - box.baseline() - seatShift(node)));
+ anchors.seat(box.page(), node, layout.pathOf(node), para, box.left(), box.right(),
+ canvasHeight - box.top(), canvasHeight - box.baseline() - seatShift(node)));
}
/**
@@ -15517,7 +15529,7 @@ private void drawWhereThePagePutsIt(XWPFDocument document, ImageNode image, byte
StringBuilder message = new StringBuilder(drewInCell
? how + ", anchored in the table cell it fills, where the layout puts it in the cell: "
+ "it moves with the row"
- : how + ", where the layout puts it, " + ANCHORED_BESIDE_ITS_TEXT);
+ : how + ", where the layout puts it, " + ANCHORED_IN_ITS_BLOCK);
for (String lost : carriedWithout(image, false)) {
message.append("; ").append(lost);
}
@@ -16018,7 +16030,7 @@ private void writeTable(XWPFDocument document, TableNode node) throws Exception
} finally {
// What no cell took is drawn where the page puts it, in the order the table paints it.
if (tableDrawings != outerDrawings) {
- DocxDrawingAnchors.Owner table = new DocxDrawingAnchors.Owner("cell drawing", layout.pathOf(node));
+ DocxDrawingAnchors.Owner table = new DocxDrawingAnchors.Owner("cell drawing", layout.pathOf(node), true);
for (CellFragment waiting : tableDrawings) {
anchors.queueOrdered(waiting.shapes(), table);
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawingTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawingTest.java
index 57e8a668a..55b1fec99 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawingTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellDrawingTest.java
@@ -233,7 +233,9 @@ void aDrawingWithAMarginStaysOnThePage() throws Exception {
}
@Test
- void aLineAloneInACellOfARowInTheFlowStaysOnThePage() throws Exception {
+ void aLineAloneInACellOfARowInTheFlowIsNotHeldToItsHeightButGoesToItsRow() throws Exception {
+ // A line may be a rule, so its cell's paragraph is not set to its height as a drawing
+ // alone in a cell is; drawn, it is its row's, and its own cell of that row carries it.
try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page
.addParagraph(p -> p.text("Above"))
.addRow("Ruled", row -> row.weights(150, 150)
@@ -242,10 +244,15 @@ void aLineAloneInACellOfARowInTheFlowStaysOnThePage() throws Exception {
.stroke(DocumentStroke.of(DocumentColor.rgb(200, 200, 200), 1)).build())
.build())
.addParagraph(p -> p.text("LABEL"))))) {
- String body = document.getDocument().xmlText();
-
- assertThat(body).as("no drawing taken into a cell").doesNotContain("layoutInCell=\"1\"");
- assertThat(shapesOutOfTheirCells(document)).as("the line, out of the cell as before").isEqualTo(1);
+ XWPFParagraph carrier = document.getTables().get(0).getRow(0).getCell(0).getParagraphs().get(0);
+
+ assertThat(anchors(carrier)).as("the line, in its own cell, from the row's top").singleElement()
+ .satisfies(line -> assertThat(line.fromV()).isEqualTo("paragraph"));
+ assertThat(carrier.getCTP().isSetPPr() && carrier.getCTP().getPPr().isSetSpacing()
+ && carrier.getCTP().getPPr().getSpacing().isSetLine()
+ && DocxTwips.of(carrier.getCTP().getPPr().getSpacing().getLine()) == 20)
+ .as("its paragraph not held to the line's height").isFalse();
+ assertThat(shapesOutOfTheirCells(document)).isZero();
}
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchorsTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchorsTest.java
index dadd0d03d..7944429cb 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchorsTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchorsTest.java
@@ -23,13 +23,16 @@
import static org.assertj.core.api.Assertions.within;
/**
- * Which paragraph a shape is anchored in, and where it is placed from.
+ * Which paragraph a shape a table's composed cell draws is anchored in, and where it is placed
+ * from; and what every shape shares, wherever anchored.
*
*
A shape anchored to the page stays where the page put it when a reader edits the text
- * above it; the text moves and the shape does not. A shape standing beside a paragraph's text is
- * anchored in that paragraph and placed down from its top, so Word moves it with the paragraph.
- * The paragraph's top is where Word places the shape from: the space written above its first
- * line included, and its line where Word stands it, not where the page draws it.
+ * above it; the text moves and the shape does not. A composed cell has no node of its own, so
+ * its shape is anchored in the paragraph whose text it stands beside, placed down from its top,
+ * and Word moves it with the paragraph. The paragraph's top is where Word places the shape from:
+ * the space written above its first line included, and its line where Word stands it, not where
+ * the page draws it. A shape a node draws is anchored by the blocks holding it
+ * ({@link DocxDrawingOwnerAnchorsTest}).
Chosen by where it stood, an icon laid over a card's corner was left to the page — the card's
+ * text starts its padding below the icon's top, more than a shape was let rise above a paragraph
+ * in a cell — and Word's editing protocol moved the card 36pt and the icon not at all. So was a
+ * skill's bar beside its label in a row: in a cell, a shape was let rise above its paragraph by a
+ * stroke's width only.
+ */
+class DocxDrawingHolderTest {
+
+ private static final SvgIcon ICON = SvgIcon.parse("");
+
+ @Test
+ void anIconLaidOverACardsCornerIsAnchoredInTheCardsText() throws Exception {
+ try (XWPFDocument document = DocxExports.withLayout(400, 500, 20, page -> page
+ .addParagraph(p -> p.text("Before the card."))
+ .addLayerStack(stack -> stack
+ .layer(new SectionBuilder().softPanel(DocumentColor.rgb(232, 240, 250), 0, 14)
+ .addParagraph(p -> p.text("The card's text.")).build())
+ .layer(ICON.node(18), LayerAlign.TOP_RIGHT))
+ .addParagraph(p -> p.text("After the card.")))) {
+ XWPFParagraph card = cellParagraphHolding(document, "The card's text.");
+ List held = anchorsIn(card.getCTP());
+
+ assertThat(held).as("the icon, in the card's paragraph").singleElement()
+ .satisfies(icon -> {
+ assertThat(icon.getLayoutInCell()).isTrue();
+ assertThat(icon.getPositionV().getRelativeFrom()).isEqualTo(STRelFromV.PARAGRAPH);
+ assertThat(points(icon.getPositionV().getPosOffset()))
+ .as("at the card's top, its padding above the text").isCloseTo(-14, within(1.0));
+ });
+ assertThat(onThePage(document)).as("nothing left to the page").isZero();
+ }
+ }
+
+ @Test
+ void aMeterBesideItsLabelInARowIsAnchoredInTheRow() throws Exception {
+ // A skill's meter: a track and its fill, drawn as lines in a stack of their own.
+ DocumentNode meter = new LayerStackBuilder().name("Meter")
+ .layer(new LineBuilder().horizontal(80).stroke(DocumentStroke.of(DocumentColor.rgb(220, 226, 236), 4)).build())
+ .layer(new LineBuilder().horizontal(50).stroke(DocumentStroke.of(DocumentColor.rgb(26, 86, 148), 4)).build(),
+ LayerAlign.TOP_LEFT)
+ .build();
+ try (XWPFDocument document = DocxExports.withLayout(400, 500, 20, page -> page
+ .addParagraph(p -> p.text("Skills"))
+ .addRow(row -> row.columns(fixed(120), weight(1))
+ .addParagraph(p -> p.text("Negotiation"))
+ .add(meter)))) {
+ assertThat(onThePage(document)).as("nothing left to the page").isZero();
+ XWPFParagraph label = cellParagraphHolding(document, "Negotiation");
+ List inTheRow = new ArrayList<>(anchorsIn(label.getCTP()));
+ XWPFTableCell barCell = ((XWPFTableCell) label.getBody()).getTableRow().getCell(1);
+ inTheRow.addAll(anchorsIn(barCell.getCTTc()));
+ assertThat(inTheRow).as("the track and its fill, in their row").hasSize(2)
+ .allSatisfy(anchor -> assertThat(anchor.getLayoutInCell()).isTrue());
+ }
+ }
+
+ @Test
+ void theHoldersOfANodeAreItAndTheBlocksAboveItButNotThePagesFlow() throws Exception {
+ DocumentNode icon = ICON.node(18);
+ try (DocumentSession session = GraphCompose.document().pageSize(400, 500).margin(DocumentInsets.of(20))
+ .create()) {
+ session.pageFlow(page -> page.addLayerStack(stack -> stack
+ .layer(new ParagraphBuilder().text("Text").build())
+ .layer(icon, LayerAlign.TOP_RIGHT)));
+ DocxLayoutMetrics layout = DocxLayoutMetrics.of(session.documentGraph(), session.layoutGraph());
+ String path = layout.pathOf(icon);
+
+ assertThat(layout.holdersOf(path)).containsExactly(path, path.substring(0, path.lastIndexOf('/')));
+ assertThat(layout.holdersOf("@timeline-rail")).as("no node's").isEmpty();
+ }
+ }
+
+ @Test
+ void aBlockTheTreeStartsAtAndAContainerFurtherInAreHolders() {
+ // Added at the root, a section is a block, not the page's flow.
+ DocumentNode icon = ICON.node(18);
+ DocumentNode section = new SectionBuilder().name("Root")
+ .add(new LayerStackBuilder().name("Stack").layer(icon).build()).build();
+ DocxLayoutMetrics atTheRoot = DocxLayoutMetrics.of(
+ new com.demcha.compose.document.layout.DocumentGraph(List.of(section)), null);
+ assertThat(atTheRoot.holdersOf(atTheRoot.pathOf(icon)))
+ .containsExactly(atTheRoot.pathOf(icon), "Root[0]/Stack[0]", "Root[0]");
+
+ // A container the flow holds is a block; only the flow the tree starts at is left out.
+ DocumentNode mark = ICON.node(18);
+ DocumentNode inner = new com.demcha.compose.document.node.ContainerNode("Inner", List.of(mark), 0,
+ DocumentInsets.zero(), DocumentInsets.zero(), null, null);
+ DocumentNode flow = new com.demcha.compose.document.node.ContainerNode("Flow", List.of(inner), 0,
+ DocumentInsets.zero(), DocumentInsets.zero(), null, null);
+ DocxLayoutMetrics inAFlow = DocxLayoutMetrics.of(
+ new com.demcha.compose.document.layout.DocumentGraph(List.of(flow)), null);
+ assertThat(inAFlow.holdersOf(inAFlow.pathOf(mark))).containsExactly(inAFlow.pathOf(mark), "Flow[0]/Inner[0]");
+ }
+
+ /** The paragraph of a table cell whose text starts so. */
+ private static XWPFParagraph cellParagraphHolding(XWPFDocument document, String text) {
+ for (XmlObject found : document.getDocument().selectPath("declare namespace w='"
+ + "http://schemas.openxmlformats.org/wordprocessingml/2006/main'"
+ + " .//w:tc//w:p")) {
+ XWPFParagraph paragraph = document.getParagraph((org.openxmlformats.schemas.wordprocessingml.x2006.main.CTP) found);
+ if (paragraph != null && paragraph.getText().startsWith(text)) {
+ return paragraph;
+ }
+ }
+ return document.getTables().stream()
+ .flatMap(table -> table.getRows().stream())
+ .flatMap(row -> row.getTableCells().stream())
+ .flatMap(cell -> paragraphsIn(cell).stream())
+ .filter(paragraph -> paragraph.getText().startsWith(text))
+ .findFirst().orElseThrow(() -> new AssertionError("no cell paragraph starts '" + text + "'"));
+ }
+
+ private static List paragraphsIn(XWPFTableCell cell) {
+ List paragraphs = new ArrayList<>(cell.getParagraphs());
+ cell.getTables().forEach(table -> table.getRows().forEach(row -> row.getTableCells()
+ .forEach(inner -> paragraphs.addAll(paragraphsIn(inner)))));
+ return paragraphs;
+ }
+
+ /** How many drawings the body places down from the page's top edge. */
+ private static long onThePage(XWPFDocument document) {
+ return anchorsIn(document.getDocument().getBody()).stream()
+ .filter(anchor -> anchor.getPositionV().getRelativeFrom() == STRelFromV.PAGE)
+ .count();
+ }
+
+ private static List anchorsIn(XmlObject root) {
+ List anchors = new ArrayList<>();
+ for (XmlObject found : root.selectPath("declare namespace wp='"
+ + "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing'"
+ + " .//wp:anchor")) {
+ anchors.add((CTAnchor) found);
+ }
+ return anchors;
+ }
+
+ private static double points(Object emu) {
+ return ((Number) emu).doubleValue() / 12700.0;
+ }
+}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingOwnerAnchorsTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingOwnerAnchorsTest.java
new file mode 100644
index 000000000..1f92847b5
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingOwnerAnchorsTest.java
@@ -0,0 +1,457 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.apache.poi.xwpf.usermodel.XWPFParagraph;
+import org.apache.poi.xwpf.usermodel.XWPFRun;
+import org.apache.poi.xwpf.usermodel.XWPFTable;
+import org.apache.poi.xwpf.usermodel.XWPFTableCell;
+import org.junit.jupiter.api.Test;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcPr;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.STTblWidth;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.STVerticalJc;
+
+import java.awt.Color;
+import java.math.BigInteger;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.within;
+
+/**
+ * A shape a node draws is anchored in the first paragraph the nearest block holding it writes on
+ * its page, and placed from there however far off it stands; one no block carries goes to the
+ * paragraph nearest it.
+ *
+ *
Chosen by where it stood, an icon laid over a card's corner stood 14pt above the card's
+ * text and was left to the page, as was every skill bar of a sidebar's rows and its portrait:
+ * edited above, the text moved and they did not. Anchored by what holds them, they move with
+ * it. Measured in Word 16.0.20527 and LibreOffice 24.2, a shape in a table cell's paragraph stands
+ * where both say at any offset — above its row, below its table, out of its cell — unless the
+ * cell sets its content from the middle or the bottom, whose text LibreOffice moves for it.
+ */
+class DocxDrawingOwnerAnchorsTest {
+
+ private static final String STACK = "Flow[0]/Stack[1]";
+ private static final String ICON = STACK + "/Icon[1]";
+
+ @Test
+ void aShapeIsAnchoredInTheFirstParagraphItsHolderWritesNotTheOneNearestIt() {
+ // The paragraph above the stack stands 6pt from the icon; the card's text, the stack's
+ // first paragraph, 14pt below it.
+ XWPFDocument document = new XWPFDocument();
+ XWPFParagraph before = text(document, "Before");
+ XWPFParagraph card = text(document, "Card");
+ XWPFParagraph more = text(document, "More");
+ DocxDrawingAnchors anchors = anchors();
+ anchors.paragraphOn(0, before, true);
+ anchors.seat(0, before, "Flow[0]/Before[0]", before, 40, 300, 94, 104);
+ anchors.seat(0, card, STACK + "/Card[0]/Text[0]", card, 54, 300, 114, 124);
+ anchors.seat(0, more, STACK + "/Card[0]/More[1]", more, 54, 300, 130, 140);
+
+ queue(anchors, ICON, List.of(dot(290, 100)));
+ anchors.endSection(0, null, document::createParagraph);
+
+ assertThat(drawingsIn(before)).isEmpty();
+ assertThat(drawingsIn(more)).isEmpty();
+ String drawing = drawingsIn(card).get(0);
+ assertThat(drawing).contains("")
+ .contains("" + emu(290) + "<");
+ assertThat(offsetDown(drawing)).as("14pt above the card's text").isCloseTo(-14, within(0.01));
+ }
+
+ @Test
+ void howFarFromItsParagraphItStandsGivesOnlyItsOffset() {
+ // Further below than any nearest paragraph's reach, and further above than a disc rises.
+ XWPFDocument document = new XWPFDocument();
+ XWPFParagraph first = text(document, "Heading");
+ DocxDrawingAnchors anchors = anchors();
+ anchors.paragraphOn(0, first, true);
+ anchors.seat(0, first, STACK + "/Heading[0]", first, 40, 300, 200, 210);
+
+ queue(anchors, ICON, List.of(dot(30, 320), dot(30, 140)));
+ anchors.endSection(0, null, document::createParagraph);
+
+ assertThat(drawingsIn(first)).extracting(DocxDrawingOwnerAnchorsTest::offsetDown)
+ .containsExactly(120.0, -60.0);
+ }
+
+ @Test
+ void inACellItRisesAboveItsParagraphAsFarAsItStands() {
+ XWPFDocument document = new XWPFDocument();
+ XWPFParagraph entry = cellParagraph(document, 200, "Entry");
+ DocxDrawingAnchors anchors = anchors();
+ anchors.paragraphOn(0, text(document, "Body"), true);
+ anchors.seat(0, entry, STACK + "/Entry[0]", entry, 110, 290, 100, 110);
+
+ queue(anchors, ICON, List.of(dot(120, 80)));
+ anchors.endSection(0, null, document::createParagraph);
+
+ String drawing = drawingsIn(entry).get(0);
+ assertThat(drawing).contains("layoutInCell=\"1\"").contains("");
+ assertThat(offsetAcross(drawing)).as("from the cell's text column").isCloseTo(10, within(0.01));
+ assertThat(offsetDown(drawing)).isCloseTo(-20, within(0.01));
+ }
+
+ @Test
+ void aCellNotHoldingTheShapeAcrossKeepsItOutOfItsColumn() {
+ // Its row has no other cell: the shape, left of the cell, is placed from the cell's column.
+ XWPFDocument document = new XWPFDocument();
+ XWPFParagraph entry = cellParagraph(document, 200, "Entry");
+ DocxDrawingAnchors anchors = anchors();
+ anchors.paragraphOn(0, text(document, "Body"), true);
+ anchors.seat(0, entry, STACK + "/Entry[0]", entry, 110, 290, 100, 110);
+
+ queue(anchors, ICON, List.of(dot(60, 104)));
+ anchors.endSection(0, null, document::createParagraph);
+
+ String drawing = drawingsIn(entry).get(0);
+ assertThat(drawing).contains("layoutInCell=\"1\"");
+ assertThat(offsetAcross(drawing)).isCloseTo(-50, within(0.01));
+ }
+
+ @Test
+ void aShapeHoldingTextIsKeptOnlyByACellHoldingItAcross() {
+ // A text box left of the cell, as the dot of the case above: LibreOffice 26.8 pulled one
+ // anchored so back into the column. The body paragraph under the table places it instead.
+ XWPFDocument document = new XWPFDocument();
+ XWPFParagraph entry = cellParagraph(document, 200, "Entry");
+ XWPFParagraph below = text(document, "Below");
+ DocxDrawingAnchors anchors = anchors();
+ anchors.paragraphOn(0, below, true);
+ anchors.seat(0, entry, STACK + "/Entry[0]", entry, 110, 290, 100, 110);
+ anchors.seat(0, below, STACK + "/Below[1]", below, 40, 300, 160, 170);
+
+ queue(anchors, ICON, List.of(DocxDrawings.Shape.textBox(60, 104, 30, 12, 0, "")));
+ anchors.endSection(0, null, document::createParagraph);
+
+ assertThat(drawingsIn(entry)).isEmpty();
+ String box = drawingsIn(below).get(0);
+ assertThat(box).contains("" + emu(60) + "<");
+ assertThat(offsetDown(box)).isCloseTo(-56, within(0.01));
+ }
+
+ @Test
+ void itsOwnCellInTheRowOfItsHoldersParagraphTakesIt() {
+ // A dot in an empty column beside its entry's text, as a timeline's: the dot's cell takes
+ // it, placed from that cell's column and the row's top.
+ XWPFDocument document = new XWPFDocument();
+ XWPFTable table = document.createTable(1, 2);
+ XWPFTableCell dotCell = table.getRow(0).getCell(0);
+ XWPFTableCell textCell = table.getRow(0).getCell(1);
+ width(dotCell, 20);
+ width(textCell, 200);
+ margin(dotCell, 0);
+ margin(textCell, 0);
+ XWPFParagraph entry = textCell.getParagraphs().get(0);
+ entry.createRun().setText("Entry");
+ DocxDrawingAnchors anchors = anchors();
+ anchors.paragraphOn(0, text(document, "Body"), true);
+ anchors.seat(0, entry, "Flow[0]/Row[1]/Text[1]", entry, 120, 320, 100, 110);
+
+ queue(anchors, "Flow[0]/Row[1]/Rail[0]/Dot[0]", List.of(dot(107, 102)));
+ anchors.endSection(0, null, document::createParagraph);
+
+ assertThat(drawingsIn(entry)).isEmpty();
+ String drawing = drawingsIn(dotCell.getParagraphs().get(0)).get(0);
+ assertThat(offsetAcross(drawing)).as("from the dot's own cell, 100pt across").isCloseTo(7, within(0.01));
+ assertThat(offsetDown(drawing)).isCloseTo(2, within(0.01));
+ }
+
+ @Test
+ void aLaterParagraphOfItsHolderNearerTheShapeDoesNotTakeItFromTheFirst() {
+ // The card's second paragraph stands 2pt from the icon, its first 14pt.
+ XWPFDocument document = new XWPFDocument();
+ XWPFParagraph card = text(document, "Card");
+ XWPFParagraph more = text(document, "More");
+ DocxDrawingAnchors anchors = anchors();
+ anchors.paragraphOn(0, card, true);
+ anchors.seat(0, card, STACK + "/Card[0]/Text[0]", card, 54, 300, 114, 124);
+ anchors.seat(0, more, STACK + "/Card[0]/More[1]", more, 54, 300, 130, 140);
+
+ queue(anchors, ICON, List.of(dot(290, 128)));
+ anchors.endSection(0, null, document::createParagraph);
+
+ assertThat(drawingsIn(more)).isEmpty();
+ assertThat(offsetDown(drawingsIn(card).get(0))).isCloseTo(14, within(0.01));
+ }
+
+ @Test
+ void aHolderWritingNoParagraphOnItsPagePassesItUp() {
+ // The stack's text is all on the page before. On the shape's page its section's heading
+ // stands 30pt above it, and a note of another block 2pt below its top.
+ XWPFDocument document = new XWPFDocument();
+ XWPFParagraph earlier = text(document, "Card");
+ XWPFParagraph heading = text(document, "Heading");
+ XWPFParagraph note = text(document, "Note");
+ DocxDrawingAnchors anchors = anchors();
+ anchors.paragraphOn(0, earlier, true);
+ anchors.paragraphOn(1, heading, true);
+ anchors.seat(0, earlier, "Flow[0]/Section[2]/Stack[0]/Card[0]", earlier, 40, 300, 700, 710);
+ anchors.seat(1, heading, "Flow[0]/Section[2]/Heading[1]", heading, 40, 300, 60, 70);
+ anchors.seat(1, note, "Flow[0]/Aside[3]/Note[0]", note, 40, 300, 92, 102);
+
+ queue(anchors, "Flow[0]/Section[2]/Stack[0]/Icon[1]", List.of(onPage(1, dot(30, 90))));
+ anchors.endSection(1, null, document::createParagraph);
+
+ assertThat(drawingsIn(earlier)).as("a paragraph on another page").isEmpty();
+ assertThat(drawingsIn(note)).as("nearer, but no holder's").isEmpty();
+ assertThat(offsetDown(drawingsIn(heading).get(0))).as("from the section's heading").isCloseTo(30, within(0.01));
+ }
+
+ @Test
+ void aCellSetToTheMiddleOrTheBottomCarriesNoneAndTheNextHolderUpDoes() {
+ for (STVerticalJc.Enum alignment : List.of(STVerticalJc.CENTER, STVerticalJc.BOTTOM)) {
+ XWPFDocument document = new XWPFDocument();
+ XWPFParagraph heading = text(document, "Heading");
+ XWPFParagraph entry = cellParagraph(document, 200, "Entry");
+ ((XWPFTableCell) entry.getBody()).getCTTc().getTcPr().addNewVAlign().setVal(alignment);
+ XWPFParagraph note = text(document, "Note");
+ DocxDrawingAnchors anchors = anchors();
+ anchors.paragraphOn(0, heading, true);
+ anchors.seat(0, heading, "Flow[0]/Section[0]/Heading[0]", heading, 40, 300, 60, 70);
+ anchors.seat(0, entry, "Flow[0]/Section[0]/Row[1]/Entry[0]", entry, 110, 290, 100, 110);
+ // Another block's paragraph, nearer the bar than the heading.
+ anchors.seat(0, note, "Flow[0]/Aside[1]/Note[0]", note, 40, 300, 104, 114);
+
+ queue(anchors, "Flow[0]/Section[0]/Row[1]/Bar[1]", List.of(dot(120, 102)));
+ anchors.endSection(0, null, document::createParagraph);
+
+ assertThat(drawingsIn(entry)).as(alignment.toString()).isEmpty();
+ assertThat(drawingsIn(note)).as(alignment + ": no holder's").isEmpty();
+ assertThat(drawingsIn(heading)).as(alignment + ": the section's").singleElement().asString()
+ .contains("");
+ }
+ }
+
+ @Test
+ void aCellHeldInACellSetToTheMiddleCarriesNoneEither() {
+ XWPFDocument document = new XWPFDocument();
+ XWPFParagraph heading = text(document, "Heading");
+ XWPFTable outer = document.createTable(1, 1);
+ XWPFTableCell holding = outer.getRow(0).getCell(0);
+ width(holding, 300);
+ holding.getCTTc().getTcPr().addNewVAlign().setVal(STVerticalJc.CENTER);
+ XWPFTable inner = new XWPFTable(holding.getCTTc().addNewTbl(), holding, 1, 1);
+ holding.insertTable(holding.getTables().size(), inner);
+ XWPFTableCell cell = inner.getRow(0).getCell(0);
+ width(cell, 200);
+ margin(cell, 0);
+ XWPFParagraph entry = cell.getParagraphs().get(0);
+ entry.createRun().setText("Entry");
+ XWPFParagraph note = text(document, "Note");
+ DocxDrawingAnchors anchors = anchors();
+ anchors.paragraphOn(0, heading, true);
+ anchors.seat(0, heading, "Flow[0]/Section[0]/Heading[0]", heading, 40, 300, 60, 70);
+ anchors.seat(0, entry, "Flow[0]/Section[0]/Row[1]/Entry[0]", entry, 110, 290, 100, 110);
+ anchors.seat(0, note, "Flow[0]/Aside[1]/Note[0]", note, 40, 300, 104, 114);
+
+ queue(anchors, "Flow[0]/Section[0]/Row[1]/Bar[1]", List.of(dot(120, 102)));
+ anchors.endSection(0, null, document::createParagraph);
+
+ assertThat(drawingsIn(entry)).isEmpty();
+ assertThat(drawingsIn(note)).isEmpty();
+ assertThat(drawingsIn(heading)).hasSize(1);
+ }
+
+ @Test
+ void aShapeNoNodeDrawsGoesToTheParagraphNearestItHoweverFar() {
+ // A timeline's rail, 80pt from the nearer of two entries.
+ XWPFDocument document = new XWPFDocument();
+ XWPFParagraph first = text(document, "First");
+ XWPFParagraph second = text(document, "Second");
+ DocxDrawingAnchors anchors = anchors();
+ anchors.paragraphOn(0, first, true);
+ anchors.seat(0, first, "Flow[0]/First[0]", first, 40, 300, 100, 110);
+ anchors.seat(0, second, "Flow[0]/Second[1]", second, 40, 300, 400, 410);
+
+ anchors.queue(List.of(dot(30, 180)), -1, new DocxDrawingAnchors.Owner("timeline rail", "@timeline-rail"));
+ anchors.endSection(0, null, document::createParagraph);
+
+ assertThat(drawingsIn(second)).isEmpty();
+ assertThat(offsetDown(drawingsIn(first).get(0))).isCloseTo(80, within(0.01));
+ }
+
+ @Test
+ void aShapeWhoseHoldersWriteNoParagraphOnItsPageGoesToTheNearest() {
+ // A badge beside a table holding the stack's text, which offers no paragraph: the heading
+ // under it is nearer than the page's first paragraph.
+ XWPFDocument document = new XWPFDocument();
+ XWPFParagraph name = text(document, "Name");
+ XWPFParagraph heading = text(document, "Heading");
+ DocxDrawingAnchors anchors = anchors();
+ anchors.paragraphOn(0, name, true);
+ anchors.seat(0, name, "Flow[0]/Name[0]", name, 40, 300, 60, 70);
+ anchors.seat(0, heading, "Flow[0]/Heading[3]", heading, 40, 300, 500, 510);
+
+ queue(anchors, "Flow[0]/Bodies[4]/Body[0]/Badge[1]", List.of(dot(300, 530)));
+ anchors.endSection(0, null, document::createParagraph);
+
+ assertThat(drawingsIn(name)).as("not the page's first paragraph").isEmpty();
+ assertThat(offsetDown(drawingsIn(heading).get(0))).isCloseTo(30, within(0.01));
+ }
+
+ @Test
+ void aParagraphThatCannotPlaceItIsPassedOverForTheNextNearest() {
+ // The nearer paragraph has a border above its line, which hides where it starts.
+ XWPFDocument document = new XWPFDocument();
+ XWPFParagraph bordered = text(document, "Bordered");
+ bordered.getCTP().addNewPPr().addNewPBdr().addNewTop()
+ .setVal(org.openxmlformats.schemas.wordprocessingml.x2006.main.STBorder.SINGLE);
+ XWPFParagraph plain = text(document, "Plain");
+ DocxDrawingAnchors anchors = anchors();
+ anchors.paragraphOn(0, bordered, true);
+ anchors.seat(0, bordered, "Flow[0]/Bordered[0]", bordered, 40, 300, 100, 110);
+ anchors.seat(0, plain, "Flow[0]/Plain[1]", plain, 40, 300, 200, 210);
+
+ anchors.queue(List.of(dot(30, 104)), -1, null);
+ anchors.endSection(0, null, document::createParagraph);
+
+ assertThat(drawingsIn(bordered)).isEmpty();
+ assertThat(offsetDown(drawingsIn(plain).get(0))).isCloseTo(-96, within(0.01));
+ }
+
+ @Test
+ void whereNoParagraphOnItsPageCanPlaceItItStaysWhereThePagePutsIt() {
+ XWPFDocument document = new XWPFDocument();
+ XWPFParagraph header = cellParagraph(document, 200, "Header");
+ ((XWPFTableCell) header.getBody()).getTableRow().setRepeatHeader(true);
+ XWPFParagraph body = text(document, "Body");
+ body.getCTP().addNewPPr().addNewContextualSpacing();
+ DocxDrawingAnchors anchors = anchors();
+ anchors.paragraphOn(0, body, true);
+ anchors.seat(0, header, STACK + "/Header[0]", header, 110, 290, 100, 110);
+ anchors.seat(0, body, STACK + "/Body[1]", body, 40, 300, 200, 210);
+
+ queue(anchors, ICON, List.of(dot(120, 102)));
+ anchors.endSection(0, null, document::createParagraph);
+
+ assertThat(drawingsIn(header)).as("a repeated header row, which Word repeats with it").isEmpty();
+ assertThat(drawingsIn(body)).singleElement().asString()
+ .contains("" + emu(102) + "<");
+ }
+
+ @Test
+ void aParagraphIsWithinItsNodeAndTheBlocksHoldingItOnly() {
+ assertThat(seatOf("Flow[0]/Stack[1]/Text[0]").isWithin("Flow[0]/Stack[1]")).isTrue();
+ assertThat(seatOf("Flow[0]/Stack[1]").isWithin("Flow[0]/Stack[1]")).as("its own node").isTrue();
+ assertThat(seatOf("Flow[0]/Stack[10]/Text[0]").isWithin("Flow[0]/Stack[1]")).as("a sibling sharing a prefix")
+ .isFalse();
+ assertThat(seatOf("Flow[0]/Stack[1]x/Text[0]").isWithin("Flow[0]/Stack[1]")).isFalse();
+ assertThat(seatOf("Flow[0]").isWithin("Flow[0]/Stack[1]")).as("a block holding it").isFalse();
+ assertThat(seatOf(null).isWithin("Flow[0]")).as("text no node writes").isFalse();
+ }
+
+ private static DocxDrawingAnchors.Seat seatOf(String path) {
+ return new DocxDrawingAnchors.Seat(null, 0, 0, 0, 0, path);
+ }
+
+ @Test
+ void aComposedCellsShapeStillGoesOnlyToTheTextNearestItWithinReach() {
+ // The same shape, 120pt below the holder's paragraph: a node's goes there, a composed
+ // cell's — which has no node — stays where the page puts it.
+ XWPFDocument document = new XWPFDocument();
+ XWPFParagraph first = text(document, "Heading");
+ DocxDrawingAnchors anchors = anchors();
+ anchors.paragraphOn(0, first, true);
+ anchors.seat(0, first, STACK + "/Heading[0]", first, 40, 300, 200, 210);
+
+ anchors.queue(List.of(dot(30, 320)), -1, new DocxDrawingAnchors.Owner("cell drawing", STACK, true));
+ anchors.endSection(0, null, document::createParagraph);
+
+ assertThat(drawingsIn(first)).singleElement().asString().contains("");
+ }
+
+ private static DocxDrawingAnchors anchors() {
+ AtomicLong ids = new AtomicLong(1);
+ return new DocxDrawingAnchors(ids::getAndIncrement, DocxGrownLines.NONE::lineOf,
+ DocxDrawingOwnerAnchorsTest::holders);
+ }
+
+ /**
+ * A path and the paths above it, nearest first, as the layout names a node's holders: the
+ * page's flow, where every path here starts, left out ({@link DocxLayoutMetrics#holdersOf}).
+ */
+ private static List holders(String path) {
+ List holders = new java.util.ArrayList<>();
+ for (String holder = path; holder != null && holder.contains("/"); holder = holder.substring(0,
+ holder.lastIndexOf('/'))) {
+ holders.add(holder);
+ }
+ return holders;
+ }
+
+ /** Queues shapes a node at a path draws. */
+ private static void queue(DocxDrawingAnchors anchors, String path, List shapes) {
+ anchors.queue(shapes, -1, new DocxDrawingAnchors.Owner("ShapeNode", path));
+ }
+
+ /** A paragraph opening the only cell of a new table, its cell 200pt wide at no stated margin. */
+ private static XWPFParagraph cellParagraph(XWPFDocument document, double cellWidth, String text) {
+ XWPFTable table = document.createTable(1, 1);
+ XWPFTableCell cell = table.getRow(0).getCell(0);
+ width(cell, cellWidth);
+ margin(cell, 0);
+ XWPFParagraph paragraph = cell.getParagraphs().get(0);
+ paragraph.createRun().setText(text);
+ return paragraph;
+ }
+
+ private static DocxDrawings.Shape onPage(int page, DocxDrawings.Shape shape) {
+ return new DocxDrawings.Shape(shape.kind(), shape.x(), shape.top(), shape.width(), shape.height(), shape.fill(),
+ shape.stroke(), shape.strokeWidth(), shape.radius(), shape.flipH(), page);
+ }
+
+ private static DocxDrawings.Shape dot(double x, double top) {
+ return new DocxDrawings.Shape(DocxDrawings.Kind.ELLIPSE, x, top, 6, 6, Color.BLUE, null, 0, 0, false, 0);
+ }
+
+ private static XWPFParagraph text(XWPFDocument document, String text) {
+ XWPFParagraph paragraph = document.createParagraph();
+ XWPFRun run = paragraph.createRun();
+ run.setText(text);
+ return paragraph;
+ }
+
+ private static void width(XWPFTableCell cell, double points) {
+ CTTcPr properties = cell.getCTTc().isSetTcPr() ? cell.getCTTc().getTcPr() : cell.getCTTc().addNewTcPr();
+ properties.addNewTcW().setType(STTblWidth.DXA);
+ properties.getTcW().setW(BigInteger.valueOf(Math.round(points * 20)));
+ }
+
+ private static void margin(XWPFTableCell cell, double points) {
+ CTTcPr properties = cell.getCTTc().isSetTcPr() ? cell.getCTTc().getTcPr() : cell.getCTTc().addNewTcPr();
+ properties.addNewTcMar().addNewLeft().setW(BigInteger.valueOf(Math.round(points * 20)));
+ }
+
+ private static List drawingsIn(XWPFParagraph paragraph) {
+ Matcher matcher = Pattern.compile("", Pattern.DOTALL)
+ .matcher(paragraph.getCTP().xmlText());
+ List drawings = new java.util.ArrayList<>();
+ while (matcher.find()) {
+ drawings.add(matcher.group());
+ }
+ return drawings;
+ }
+
+ private static double offsetDown(String drawing) {
+ return offset(drawing, "positionV");
+ }
+
+ private static double offsetAcross(String drawing) {
+ return offset(drawing, "positionH");
+ }
+
+ private static double offset(String drawing, String axis) {
+ Matcher matcher = Pattern.compile("(-?\\d+)<")
+ .matcher(drawing);
+ assertThat(matcher.find()).isTrue();
+ return Long.parseLong(matcher.group(1)) / 12700.0;
+ }
+
+ private static long emu(double points) {
+ return org.apache.poi.util.Units.toEMU(points);
+ }
+}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingsTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingsTest.java
index 7ef917df7..ad23c6fb6 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingsTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingsTest.java
@@ -357,14 +357,19 @@ void aShapeBesideASeatedTitleIsPlacedFromWhereWordStandsTheTitle() throws Except
}
@Test
- void aShapeWithNoParagraphWithinReachStaysOnThePage() throws Exception {
+ void aShapeNoBlockHoldsGoesToTheParagraphNearestItHoweverFar() throws Exception {
+ // The page's flow holds the dot and nothing else does: the paragraph 80pt above takes it,
+ // further off than the 48pt a composed cell's drawing may stand from its text.
try (XWPFDocument document = export(null, session -> session.pageFlow(page -> page
.addParagraph(p -> p.text("Above"))
.addSpacer(s -> s.height(80))
.addEllipse(e -> e.name("Dot").circle(16).fillColor(ACCENT))))) {
- assertThat(anchors(document.getDocument().xmlText())).singleElement().asString()
- .contains("")
- .contains("");
+ org.apache.poi.xwpf.usermodel.XWPFParagraph above = document.getParagraphs().stream()
+ .filter(paragraph -> paragraph.getText().equals("Above")).findFirst().orElseThrow();
+ String anchor = anchors(above.getCTP().xmlText()).get(0);
+ assertThat(anchor).contains("")
+ .contains("");
+ assertThat(offsetDown(anchor)).isGreaterThan(80);
}
}
@@ -597,11 +602,13 @@ void aShapeBesideAColumnsTextIsAnchoredInItsCellAndMovesWithIt() throws Exceptio
@Test
void aFirstPageLaidOutInATableGetsABodyParagraphBeforeItForItsShapes() throws Exception {
- // Two columns written as one row: the page has no body paragraph, and the ring has no
- // paragraph beside it in its own column. A hairline paragraph opens the page for it.
+ // Two columns written as one row: the page has no body paragraph, and its one paragraph,
+ // centred in its cell, does not tell where that cell's column starts to place the ring
+ // from. A hairline paragraph opens the page for it.
try (XWPFDocument document = export(null, session -> session.pageFlow(page -> page
.addLayerStack(stack -> stack
- .layer(column("Side", 0, 260, side -> side.addParagraph(p -> p.text("Contact"))),
+ .layer(column("Side", 0, 260, side -> side.addParagraph(p -> p.text("Contact")
+ .align(com.demcha.compose.document.node.TextAlign.CENTER))),
com.demcha.compose.document.node.LayerAlign.TOP_LEFT)
.layer(column("Main", 100, 0, main -> main
.addEllipse(e -> e.name("Ring").circle(40).fillColor(ACCENT))),
@@ -826,9 +833,11 @@ void aLaterSectionOpeningWithATableGetsItsBodyParagraphBeforeThatTable() throws
first.pageFlow(page -> page.addParagraph(p -> p.text("Cover")));
DocumentSession second = GraphCompose.document().pageSize(400, 400).margin(DocumentInsets.of(20)).create();
second.pageFlow(page -> page.addLayerStack(stack -> stack
- .layer(column("Side", 0, 260, side -> side.addParagraph(p -> p.text("Contact"))),
+ .layer(column("Side", 0, 260, side -> side.addParagraph(p -> p.text("Contact")
+ .align(com.demcha.compose.document.node.TextAlign.CENTER))),
com.demcha.compose.document.node.LayerAlign.TOP_LEFT)
- // The ring alone in its column: no paragraph stands beside it.
+ // The ring alone in its column, and the page's one paragraph centred: nothing on
+ // the page can place it.
.layer(column("Main", 100, 0, main -> main
.addEllipse(e -> e.name("Ring").circle(40).fillColor(ACCENT))),
com.demcha.compose.document.node.LayerAlign.TOP_LEFT)));
From c65c98d655b90239e7d146674fc57e1202e9f649 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Tue, 27 Oct 2026 13:00:27 +0000
Subject: [PATCH 2/4] docs(docx): say how a text box over the flow is anchored,
and pin where a held drawing falls back
A text box laid over the flow is anchored as any drawing a node paints, and
its note now says so. The nearest-text limits (REACH, FURTHEST_RISE, the
stroke's rise in a cell) say they hold only what a composed cell draws;
holdersOf says a container at the tree's root is taken to be the page's
flow; the CHANGELOG and the capability matrix name the paragraph's own
cell keeping a drawing out of its column, and a cell held in one set to the
middle.
Tests pin a block's next paragraph taking a drawing its first cannot, a
drawing's own cell merged from above leaving it to the paragraph's cell,
a ring alone in its column taking its own cell with no hairline before the
table, and a meter taking its own cell of its row.
---
CHANGELOG.md | 7 +--
.../architecture/backend-capability-matrix.md | 2 +-
.../semantic/docx/DocxDrawingAnchors.java | 36 +++++++------
.../semantic/docx/DocxLayoutMetrics.java | 12 +++--
.../semantic/docx/DocxSemanticBackend.java | 3 +-
.../semantic/docx/DocxDrawingHolderTest.java | 10 ++--
.../docx/DocxDrawingOwnerAnchorsTest.java | 52 +++++++++++++++++++
.../semantic/docx/DocxDrawingsTest.java | 27 ++++++++++
.../docx/DocxParagraphReportTest.java | 6 ++-
9 files changed, 124 insertions(+), 31 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 15197e437..6c575803e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,9 +19,10 @@ follow semantic versioning; release dates are ISO 8601.
nearest block holding it writes on its page — the node itself, a layer stack, a panel, a row,
but not the page's flow —, placed from that paragraph's top however far from its text it
stands. In a table cell, the cell of that paragraph's row holding the drawing across takes it,
- its own where it stands in a column of its own; another cell keeps it placed out of its
- column, unless it holds text — a text box, a badge's letters —, which LibreOffice 26.8 pulls
- back into the column. A cell set to the middle or the bottom carries none: the block's next
+ its own where it stands in a column of its own; otherwise the paragraph's own cell keeps it,
+ placed out of its column, unless it holds text — a text box, a badge's letters —, which
+ LibreOffice 26.8 pulls back into the column. A cell set to the middle or the bottom, or
+ sitting in one, carries none: the block's next
paragraph able to place it takes the drawing, or else the next block up. A drawing no node
paints — a timeline's rail — and one whose blocks write no such paragraph on its page go to
the paragraph nearest it there. What a table's composed cell draws keeps the nearest-text
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index a5e6f441c..c72562112 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -73,7 +73,7 @@ Payload records live in `core` under
| Inline vector shapes (`ParagraphShapeSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` (distinct per-corner radii render with the top-left radius — single-adjust preset) | ⚠️ `DocxSemanticBackend.writeInlinePicture` + `DocxShapePictures` (a transparent PNG drawn by the shared `InlineSvgRasters` from the outline, fill and stroke — every outline kind, each layer centred in the run's box — placed as an inline picture is; the picture takes as far as the stroked ink reaches past the outline — half the stroke on an edge, more at a sharp corner's miter — and a pixel on each side, measured side by side, and is lowered by what it takes below, so no edge is cut and a shape takes that much more room in the line, which the text beside it gives back in whole tenths of a point a letter where it has letters on one line (`giveBackWidth`) — where none does, the shape's note says, from half a point, up to how far that moves it and what stands beside it in its line, as Word sets the line from the side its alignment names, the paragraph taken as one line; what the tenths miss, up to a twentieth of a point a letter and all of it in a run too long for a tenth a letter, is not named, a quarter point at most on the corpus; a list marker that draws a disc is its picture; at the top level of a `hangingIndent(true)` list that does not nest it is followed by a tab to where the layout starts the item's text, the item's lines hanging there, when the picture clears that stop, else by a space; Word could hold a shape in the line, `wp:inline` round a `wps:wsp`, which the export does not write, so each shape among the words is reported `APPROXIMATED` as a picture) |
| Inline SVG (`ParagraphSvgSpan`) | ✅ `PdfParagraphFragmentRenderHandler` + `PdfPathPainter` | ⚠️ `PptxParagraphFragmentRenderHandler` + `PptxInlineGeometry` + `PptxInlineSvgRasterizer` (simple layers stay native; arbitrary clips, exact dash/cap/join styles, and off-viewBox art use a transparent PNG fallback — drawn by the shared `InlineSvgRasters`; gradient paints use their primary colour) | ⚠️ `DocxSemanticBackend.writeInlinePicture` (always the transparent PNG: the same layers the layout resolves, through `InlineSvgLayers`, drawn by the raster the PPTX fallback uses — `InlineSvgRasters`, four pixels a point — and placed as an inline picture is; emoji included, so an emoji is a picture rather than a character; every icon is reported `APPROXIMATED` as a picture, an icon's text as its description, and a gradient, which the raster draws flat in its first colour) |
| Text an inline icon stands for — copy, search, extraction (`ParagraphSvgSpan.text`, set by `SvgIcon.withText` and on every `EmojiLibrary` emoji) | ✅ `PdfTextLayer` via `PdfRenderEnvironment.writeTextLayer`: one invisible glyph over the icon on the line's baseline, from a Type 3 font of empty glyphs whose `ToUnicode` states each text, a whole ZWJ sequence included; rendering mode 3, so nothing is painted. One font per document, a new one after 255 distinct texts; a text over 256 UTF-16 units is not written. A block icon (`addSvgIcon`, `SvgIcon.node`) writes no text. `ActualText` around the paths was measured to reach none of PDFBox, poppler, pdf.js and MuPDF — it replaces glyphs, and a drawing has none. In a right-to-left line the glyph sits between the words it was written between and states its whole text; reading such a line back, PDFBox reverses the emoji one UTF-16 unit at a time and poppler reverses the code points of a ZWJ sequence or a U+FE0F pair, while pdf.js and MuPDF keep it whole (measured; a reader-side reversal of the glyph's text) | ❌ the icon is drawn and its text is not written | ⚠️ the icon is a picture whose description (`docPr/@descr`) is its text — read by a screen reader, but not a character a reader copies or searches |
-| Rectangle shape — fill, stroke, per-corner radii, side borders (`ShapeFragmentPayload`) | ✅ `PdfShapeFragmentRenderHandler` | ⚠️ `PptxShapeFragmentRenderHandler` (distinct per-corner radii render with the top-left radius on all corners — single-adjust `roundRect` preset — until custom geometry lands; uniform radii and side borders exact) | ⚠️ `DocxSemanticBackend.writeContainerChildren` — a `SectionNode` or `ContainerNode` carrying a fill, per-side borders or a uniform stroke is written as a one-cell table, and so is a `RowNode` in the flow that paints its box (`writePaintedRow`, through the same `writePanelPiece`), its columns a table nested in the cell without its padding — not in a band of layers or a stack's measured column, with a padding or margin it cannot hold, composed in a table cell or in a page zone, where it is its columns alone and its paint is reported: the cell's shading is the fill behind everything inside, its borders are the container's at its full height, and its margins are the padding on all four sides, less half of each border, with the table half a border wider on each side, so the text and the border land where the page draws them — in a cell, a panel set in from the cell's text by half its left border or more keeps its whole left padding as its left margin, and one set in less what it is set in by of that half, as both editors set a nested table's text its left border's centre and its margin in — in the body, the left margin is the whole padding, the table no wider on that side, and its indent places its text, as Word 16 and LibreOffice on Windows centre a body table's left border on its edge (an older LibreOffice, the Linux CI's, reads the indent as the table's edge and sets the text about a padding right). Word sets a cell's content its whole top border and its margin inside the row, and ends the row its margin and its whole bottom border past the content, so half of each border — and as much of its inner half as the padding falls short of it — reaches past the panel: the space round it takes that first, then its padding, the border then drawn that much inside the panel's edge, then, above, the room over its first line; what none of them takes is reported (`space above` on the block below, the panel's kind for its top border). What its content leaves below itself past where the page ends it — a last line held to an icon, a card's border ending it, which that card's padding takes first — comes out of its padding below first, the same way; a card's border ending a row's cell reaches past the row only by what the row's room under that cell does not hold, and one ending a table's composed cell is reported on the card, its row up to that much taller. A negative bottom margin pulls what follows up out of the space between them, and what that space cannot give is reported, as is the tenth of a point of the paragraph between any two tables where the space between them does not take it. A shape container's borders stay inside its outline. A panel the layout measures holds the page's height less its padding as its row's height, which Word and LibreOffice on Windows read as its content's, its margins and borders round it; a shape container holds its outline's, less its margins and its borders as wide as Word draws them, two and a half twips an eighth of a point rounded down. A panel's borders are written from two to ninety-six eighths of a point, as a table cell's rules and a rule are, and a width that, rounded to the eighth, is thinner or wider is named (`border width`). The table takes the width the layout placed the container at. The point of slack an editor's face is given past it comes out of the panel's right margin, so its fill and borders end where the page ends them; where the margin cannot hold it, or a line Word sets at its size to the half point runs further into the margin than the rest holds, the table keeps that much of the slack and the report names it (`right end`). In a cell, a panel whose left border hangs half its width left of the cell's text — or which a margin below zero sets further out — stands up to that much right of the page's, as LibreOffice and Word start a nested table at the cell's text, which the report names (`left edge`); it gives up what its left side had given its text, the half border the table is wider by and what its left margin gave back of the other half, as both editors start it half that border in, so its right end stands where the page's does, and a nested table running past its cell, which widens the cell, fixed layout or not, runs past no further — what more it gives up held in at the cell's edge than at its text coming off its right margin, what holding its right border in has already taken counting towards it, and what of it the cell does not take going back to a line set past its text further than its margin holds, reported (`right end`); one whose right border reaches the cell's edge — the cell's own right margin holds what passes its text, where the cell draws no right rule of its own that may cover it — ends that border and half a point short of it, the points taken from its right margin and no more than it holds, since Word draws a nested table's right border outside the table and, on screen, cuts off what passes the cell's edge, and the report names how much narrower than the page draws the panel its table is written, and about how far what the margin cannot take in still passes that edge (`right border`). A `keepTogether()` panel the layout held on one page is a row that may not split. Panels, rows and tables inside it are tables in its cell; a table cell no style fills is written white, as the page draws it. One deviation: the corner radius is dropped (a cell is rectangular) with one warning per export. A translucent fill is flattened against what Word paints under the panel: the panel or cell around it as written, or on the page the colour the layout paints there (`DocxLayoutMetrics.colourUnder`: the fills drawn before it, at its centre, a page background included and a row's own fill left out, what stands on it being written in the row's panel, on its shading). A translucent border is flattened against the panel's fill where it has one, since a cell's shading and borders are opaque; each is named in the report as `translucency`. An unpainted container is not a table: its paragraphs are indented by every enclosing margin and padding, and its rows and tables get `w:tblInd`. A standalone `ShapeNode` that is only a fill, with no stroke, radius, gradient or transform, and no taller than Word's thickest border (12pt) — an `addDivider` — is a rule, written as a line's is; any other `ShapeNode`, standalone or laid over others, is drawn by `DocxDrawings` as a floating DrawingML shape behind the text, where the layout placed it — `rect`, or `roundRect` with the largest corner radius, in its fill and outline colours with their alpha, the outline mitred at its corners as the page strokes it, each side border a line along its edge; a gradient paint and a transform are not carried, nor are a link, an outline entry or an anchor's bookmark — each named in the shape's report note, as unequal corners are —, and a shape with neither a fill colour nor an outline is dropped and reported. It is anchored in the first paragraph the nearest block holding it writes on its page — in a table cell its own cell of that paragraph's row where one holds it across, none in a cell set to the middle or the bottom, whose text LibreOffice 24.2 then sets from the top — placed down from its top however far off, so it moves with that block when the text above is edited; where no such block writes one, in the paragraph nearest it on its page; what a composed cell draws, in the paragraph whose text is nearest it within reach; with none of these, in a body paragraph on its page (a table cell's only on a page with no other, reported), where it stays — except a drawing that is all a table cell holds, an icon beside its label (in a row of the flow, a layer stack or shape container that only draws, with no margins, on one page and painting nothing outside its box; in a composed cell, a layer stack of shapes with no margin or padding, the only drawing the cell holds, matched to the table's fragments inside the cell by kind and size), which is anchored in that cell's paragraph, held at its height and placed from its top and the cell's text column, so it moves with its row, and down with the paragraph's line where space is added above that line once it is placed, as a lifted row's cell starts lower; a drawing holding a line is not held at its height, and goes by its block —, and is drawn in front of the text inside a filled panel unless it frames text or a picture there; reported `APPROXIMATED` |
+| Rectangle shape — fill, stroke, per-corner radii, side borders (`ShapeFragmentPayload`) | ✅ `PdfShapeFragmentRenderHandler` | ⚠️ `PptxShapeFragmentRenderHandler` (distinct per-corner radii render with the top-left radius on all corners — single-adjust `roundRect` preset — until custom geometry lands; uniform radii and side borders exact) | ⚠️ `DocxSemanticBackend.writeContainerChildren` — a `SectionNode` or `ContainerNode` carrying a fill, per-side borders or a uniform stroke is written as a one-cell table, and so is a `RowNode` in the flow that paints its box (`writePaintedRow`, through the same `writePanelPiece`), its columns a table nested in the cell without its padding — not in a band of layers or a stack's measured column, with a padding or margin it cannot hold, composed in a table cell or in a page zone, where it is its columns alone and its paint is reported: the cell's shading is the fill behind everything inside, its borders are the container's at its full height, and its margins are the padding on all four sides, less half of each border, with the table half a border wider on each side, so the text and the border land where the page draws them — in a cell, a panel set in from the cell's text by half its left border or more keeps its whole left padding as its left margin, and one set in less what it is set in by of that half, as both editors set a nested table's text its left border's centre and its margin in — in the body, the left margin is the whole padding, the table no wider on that side, and its indent places its text, as Word 16 and LibreOffice on Windows centre a body table's left border on its edge (an older LibreOffice, the Linux CI's, reads the indent as the table's edge and sets the text about a padding right). Word sets a cell's content its whole top border and its margin inside the row, and ends the row its margin and its whole bottom border past the content, so half of each border — and as much of its inner half as the padding falls short of it — reaches past the panel: the space round it takes that first, then its padding, the border then drawn that much inside the panel's edge, then, above, the room over its first line; what none of them takes is reported (`space above` on the block below, the panel's kind for its top border). What its content leaves below itself past where the page ends it — a last line held to an icon, a card's border ending it, which that card's padding takes first — comes out of its padding below first, the same way; a card's border ending a row's cell reaches past the row only by what the row's room under that cell does not hold, and one ending a table's composed cell is reported on the card, its row up to that much taller. A negative bottom margin pulls what follows up out of the space between them, and what that space cannot give is reported, as is the tenth of a point of the paragraph between any two tables where the space between them does not take it. A shape container's borders stay inside its outline. A panel the layout measures holds the page's height less its padding as its row's height, which Word and LibreOffice on Windows read as its content's, its margins and borders round it; a shape container holds its outline's, less its margins and its borders as wide as Word draws them, two and a half twips an eighth of a point rounded down. A panel's borders are written from two to ninety-six eighths of a point, as a table cell's rules and a rule are, and a width that, rounded to the eighth, is thinner or wider is named (`border width`). The table takes the width the layout placed the container at. The point of slack an editor's face is given past it comes out of the panel's right margin, so its fill and borders end where the page ends them; where the margin cannot hold it, or a line Word sets at its size to the half point runs further into the margin than the rest holds, the table keeps that much of the slack and the report names it (`right end`). In a cell, a panel whose left border hangs half its width left of the cell's text — or which a margin below zero sets further out — stands up to that much right of the page's, as LibreOffice and Word start a nested table at the cell's text, which the report names (`left edge`); it gives up what its left side had given its text, the half border the table is wider by and what its left margin gave back of the other half, as both editors start it half that border in, so its right end stands where the page's does, and a nested table running past its cell, which widens the cell, fixed layout or not, runs past no further — what more it gives up held in at the cell's edge than at its text coming off its right margin, what holding its right border in has already taken counting towards it, and what of it the cell does not take going back to a line set past its text further than its margin holds, reported (`right end`); one whose right border reaches the cell's edge — the cell's own right margin holds what passes its text, where the cell draws no right rule of its own that may cover it — ends that border and half a point short of it, the points taken from its right margin and no more than it holds, since Word draws a nested table's right border outside the table and, on screen, cuts off what passes the cell's edge, and the report names how much narrower than the page draws the panel its table is written, and about how far what the margin cannot take in still passes that edge (`right border`). A `keepTogether()` panel the layout held on one page is a row that may not split. Panels, rows and tables inside it are tables in its cell; a table cell no style fills is written white, as the page draws it. One deviation: the corner radius is dropped (a cell is rectangular) with one warning per export. A translucent fill is flattened against what Word paints under the panel: the panel or cell around it as written, or on the page the colour the layout paints there (`DocxLayoutMetrics.colourUnder`: the fills drawn before it, at its centre, a page background included and a row's own fill left out, what stands on it being written in the row's panel, on its shading). A translucent border is flattened against the panel's fill where it has one, since a cell's shading and borders are opaque; each is named in the report as `translucency`. An unpainted container is not a table: its paragraphs are indented by every enclosing margin and padding, and its rows and tables get `w:tblInd`. A standalone `ShapeNode` that is only a fill, with no stroke, radius, gradient or transform, and no taller than Word's thickest border (12pt) — an `addDivider` — is a rule, written as a line's is; any other `ShapeNode`, standalone or laid over others, is drawn by `DocxDrawings` as a floating DrawingML shape behind the text, where the layout placed it — `rect`, or `roundRect` with the largest corner radius, in its fill and outline colours with their alpha, the outline mitred at its corners as the page strokes it, each side border a line along its edge; a gradient paint and a transform are not carried, nor are a link, an outline entry or an anchor's bookmark — each named in the shape's report note, as unequal corners are —, and a shape with neither a fill colour nor an outline is dropped and reported. It is anchored in the first paragraph the nearest block holding it writes on its page — in a table cell its own cell of that paragraph's row where one holds it across, else the paragraph's cell placing it out of its column (one holding text, a text box or a badge's letters, only where it holds it across), none in a cell set to the middle or the bottom or sitting in one, whose text LibreOffice 24.2 then sets from the top — placed down from its top however far off, so it moves with that block when the text above is edited; where no such block writes one, in the paragraph nearest it on its page; what a composed cell draws, in the paragraph whose text is nearest it within reach; with none of these, in a body paragraph on its page (a table cell's only on a page with no other, reported), where it stays — except a drawing that is all a table cell holds, an icon beside its label (in a row of the flow, a layer stack or shape container that only draws, with no margins, on one page and painting nothing outside its box; in a composed cell, a layer stack of shapes with no margin or padding, the only drawing the cell holds, matched to the table's fragments inside the cell by kind and size), which is anchored in that cell's paragraph, held at its height and placed from its top and the cell's text column, so it moves with its row, and down with the paragraph's line where space is added above that line once it is placed, as a lifted row's cell starts lower; a drawing holding a line is not held at its height, and goes by its block —, and is drawn in front of the text inside a filled panel unless it frames text or a picture there; reported `APPROXIMATED` |
| Ellipse (`EllipseFragmentPayload`) | ✅ `PdfEllipseFragmentRenderHandler` | ✅ `PptxEllipseFragmentRenderHandler` | ⚠️ `DocxDrawings` — an `ellipse` shape anchored as a rectangle is; a shape container's elliptical outline is drawn the same way, and a picture that fills the container it clips takes the ellipse as its geometry; a transform is not carried, nor a link, an outline entry or an anchor's bookmark (each named in the ellipse's report note), and a shape in a filled panel is drawn in front of the text, over the cell's shading, unless it frames text or a picture; a shape is anchored as a rectangle is — except, as for a rectangle, a drawing that is all a table cell holds — in a row of the flow, a badge alone beside its text — which is anchored in that cell and moves with its row |
| Line — dash pattern, line cap (`LineFragmentPayload`) | ✅ `PdfLineFragmentRenderHandler` | ⚠️ `PptxLineFragmentRenderHandler` (numeric dash arrays map to the generic dashed preset; solid lines and caps exact) | ⚠️ `DocxSemanticBackend.writeRule` — a horizontal line with no transform, stroked no thicker than Word's 12pt border, is Word's own rule: an empty paragraph whose bottom border is the stroke (colour, thickness in eighths of a point, a hairline thinner than Word's thinnest drawn at it and reported, `border width`), its ends as the paragraph's indents, each taken in by what Word draws a paragraph's border past it — 1.42pt at the left and 1.51pt at the right, measured 1.36 to 1.59; a rule running past the width it is written in held at that width's edge, less only what Word draws further than the page ends it — so LibreOffice, which draws it between them, sets each end that much further in, and a rule shorter than both keeps a tenth of a point of its paragraph, and the space above and below the stroke in its box as the paragraph's height and the space owed below it, and what Word's paragraph stands taller than the box — the tenth of a point of line a stroke with no room above it is held to, and the border counted in whole twips of its eighths — taken out of the space owed below and, past that, hung below the rule for the space above what follows to take, as a line hanging below a block is; a dash pattern becomes Word's dashed or dotted border, reported `APPROXIMATED`; a translucent stroke is flattened against the colour the page paints under it, a page background included, reported as `translucency`; the line cap and a link are not carried (both reported, a link as `rule link`; an outline entry on it is reported too); composed in a table's cell, it is written once, where the page draws it, and its table does not draw it again — unless it stands in a panel or a row inside the cell, or another drawing of its kind and size there cannot be told from it, where both stay and the report says it stands twice. A thicker line (holding its room in the flow and among the text of a stack of one layer, none in a table's cell, reported), a vertical or slanted line, and a line laid over others in a layer stack, canvas or shape container — a line among the text of a layer stack of one layer excepted, which is a rule —, is drawn by `DocxDrawings` as a `line` shape anchored as a rectangle is, ending in the page's cap — the dash pattern and a transform are not carried, nor a link, an outline entry or an anchor's bookmark (each named in the line's report note); a line in a page zone is dropped and reported |
| Polygon (`PolygonFragmentPayload`) | ✅ `PdfPolygonFragmentRenderHandler` | ✅ `PptxPolygonFragmentRenderHandler` + `PptxInlineGeometry` | ⚠️ `DocxDrawings` + `DocxCustomGeometry` — `a:custGeom`, the vertex ring closed, its corners mitred as the page strokes them (LibreOffice bevels one sharper than 30 degrees), anchored as a rectangle is |
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java
index 3f07cd8e3..fb79e4916 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingAnchors.java
@@ -44,10 +44,11 @@
* shapes wait for the section's end, since the paragraph carrying a shape may be written after
* it.
*
- *
A shape beside no paragraph is positioned from the page's edges, so any paragraph on its
- * page carries it to the right place — but only one on that page: an anchor takes its shape to
- * the page the anchoring paragraph lands on. And a paragraph of the body rather than of a table
- * cell: Word measures a shape anchored in a cell from the cell, not the page. The first body
+ *
A shape no paragraph on its page can carry is positioned from the page's edges, so any
+ * paragraph on its page carries it to the right place — but only one on that page: an anchor
+ * takes its shape to the page the anchoring paragraph lands on. And a paragraph of the body
+ * rather than of a table cell: Word measures a shape anchored in a cell from the cell, not the
+ * page. The first body
* paragraph written on each page carries such shapes. The run is put at the front of the
* paragraph, so a paragraph that runs on to the next page does not take the shape with it.
*
@@ -266,7 +267,7 @@ void queue(List shapes, int painted, Owner owner) {
/** Keeps shapes given their places in the paint order for the section's end, as {@link #queue} does. */
void queueOrdered(List shapes, Owner owner) {
- // Held until the section ends: the paragraph a shape stands beside may be written after it.
+ // Held until the section ends: the paragraph carrying a shape may be written after it.
for (Ordered shape : shapes) {
pending.computeIfAbsent(shape.shape().page(), page -> new ArrayList<>()).add(shape);
if (owner != null) {
@@ -317,7 +318,7 @@ private void takeTheSeats() {
/**
* Records a paragraph written on a page. The page's first body paragraph carries the shapes
- * no paragraph beside them does; a cell's is kept in case the page has no other.
+ * no other paragraph does; a cell's is kept in case the page has no other.
*
* @param page the page, counted within the section
* @param paragraph a paragraph the layout puts on that page
@@ -569,8 +570,10 @@ private Place placeBeside(DocxDrawings.Shape shape) {
}
/**
- * Anchors shapes in the paragraph whose text they stand nearest, placed down from its top,
- * in the order they are drawn: they move with the paragraph wherever an edit takes it.
+ * Anchors shapes in the paragraph each was placed from — a paragraph of a block holding it,
+ * the one nearest it, or for a composed cell's the one whose text it stands nearest — placed
+ * down from its top, in the order they are drawn: they move with the paragraph wherever an
+ * edit takes it.
*/
private void anchorBeside(XWPFParagraph paragraph, List shapes) {
XWPFRun run = paragraph.insertNewRun(0);
@@ -591,9 +594,10 @@ private void anchorBeside(XWPFParagraph paragraph, List shapes) {
}
/**
- * How far from a paragraph's text a shape still stands beside it, across and down added, in
- * points: a timeline's dot beside its entry, a rail from its first entry, an icon beside its
- * heading. A shape further from every paragraph stays where the page puts it.
+ * How far from a paragraph's text a shape a table's composed cell draws still stands beside
+ * it, across and down added, in points: an icon beside its label in another cell. One further
+ * from every paragraph stays where the page puts it. A shape a node draws goes by the blocks
+ * holding it, however far off ({@link #placeInItsHolder}).
*/
private static final double REACH = 48;
@@ -601,11 +605,11 @@ private void anchorBeside(XWPFParagraph paragraph, List shapes) {
private static final double SLACK = 1;
/**
- * The furthest a shape rises above its paragraph's top in the body, in points: as far as a
- * dot or an icon's disc centred on a line of text does — NavySidebar's 22.6pt badge is the
- * corpus's largest — where Word and LibreOffice were measured placing 6pt and 10pt dots up
- * to 8pt above their paragraphs. A taller shape — a rail, an accent bar — rising further
- * stands by other text.
+ * The furthest a shape a table's composed cell draws rises above its paragraph's top in the
+ * body, in points: as far as a dot or an icon's disc centred on a line of text does, as
+ * NavySidebar's 22.6pt badge did when every shape was placed by the text nearest it — Word and
+ * LibreOffice were measured placing 6pt and 10pt dots up to 8pt above their paragraphs. A
+ * taller shape — a rail, an accent bar — rising further stands by other text.
*/
private static final double FURTHEST_RISE = 24;
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java
index 0be0c7504..e69cda22e 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java
@@ -877,11 +877,13 @@ String pathOf(DocumentNode node) {
* The paths of a node and of the blocks holding it, nearest first: where a drawing it paints
* looks for the paragraph that carries it ({@code DocxDrawingAnchors}).
*
- *
A container the tree starts at is the page's flow, holding every block on its pages and
- * so none in particular: it is left out. Any other node the tree starts at — a section or a
- * stack added at the root rather than in a flow — is a block and stays, as does a container
- * further in. A path the tree does not name — a pass of the layout's own, a timeline's rail —
- * has no holders.
+ *
A container the tree starts at is taken to be the page's flow, holding every block on its
+ * pages and so none in particular, and is left out: a page flow is one, and every document of
+ * the fidelity corpus starts at one. A painted container added at the root is left out too,
+ * its drawings going to the paragraph nearest them. Any other node the tree starts at — a
+ * section or a stack added at the root — is a block and stays, as does a container further in.
+ * A path the tree does not name — a pass of the layout's own, a timeline's rail — has no
+ * holders.
*
* @param path a node's path ({@link #pathOf})
* @return its holders' paths, itself first; empty for a path no node of the tree has
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
index a7650130d..dfdc083b2 100644
--- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
+++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
@@ -4455,7 +4455,8 @@ private void writeTextOverTheFlow(XWPFDocument document, ParagraphNode node) {
paragraphXml(para))), layout.paintIndexOf(node),
new DocxDrawingAnchors.Owner(node.nodeKind(), layout.pathOf(node)));
List lost = new ArrayList<>();
- lost.add("laid over the flow, which gives it no room: set in a text box where the page sets it");
+ lost.add("laid over the flow, which gives it no room: set in a text box where the page sets it, "
+ + ANCHORED_IN_ITS_BLOCK);
lost.addAll(paragraphLost(node, roomLostInItsBox(node), null));
report.add(DocxExportReport.Severity.APPROXIMATED, node.nodeKind(), layout.pathOf(node),
String.join("; ", lost));
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingHolderTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingHolderTest.java
index 5444bf0f3..8c8e0f846 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingHolderTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingHolderTest.java
@@ -81,11 +81,13 @@ void aMeterBesideItsLabelInARowIsAnchoredInTheRow() throws Exception {
.add(meter)))) {
assertThat(onThePage(document)).as("nothing left to the page").isZero();
XWPFParagraph label = cellParagraphHolding(document, "Negotiation");
- List inTheRow = new ArrayList<>(anchorsIn(label.getCTP()));
XWPFTableCell barCell = ((XWPFTableCell) label.getBody()).getTableRow().getCell(1);
- inTheRow.addAll(anchorsIn(barCell.getCTTc()));
- assertThat(inTheRow).as("the track and its fill, in their row").hasSize(2)
- .allSatisfy(anchor -> assertThat(anchor.getLayoutInCell()).isTrue());
+ assertThat(anchorsIn(label.getCTP())).as("not the label's paragraph").isEmpty();
+ assertThat(anchorsIn(barCell.getCTTc())).as("the track and its fill, in their own cell of the row")
+ .hasSize(2).allSatisfy(anchor -> {
+ assertThat(anchor.getLayoutInCell()).isTrue();
+ assertThat(anchor.getPositionV().getRelativeFrom()).isEqualTo(STRelFromV.PARAGRAPH);
+ });
}
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingOwnerAnchorsTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingOwnerAnchorsTest.java
index 1f92847b5..4a92f6e81 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingOwnerAnchorsTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingOwnerAnchorsTest.java
@@ -226,6 +226,58 @@ void aCellSetToTheMiddleOrTheBottomCarriesNoneAndTheNextHolderUpDoes() {
}
}
+ @Test
+ void aHoldersNextParagraphTakesItWhereItsFirstCannot() {
+ // The card's first paragraph stands in a cell set to the middle; its caption under the
+ // table is the card's too. Another block's note stands nearer the icon than the caption.
+ XWPFDocument document = new XWPFDocument();
+ XWPFParagraph entry = cellParagraph(document, 200, "Entry");
+ ((XWPFTableCell) entry.getBody()).getCTTc().getTcPr().addNewVAlign().setVal(STVerticalJc.CENTER);
+ XWPFParagraph caption = text(document, "Caption");
+ XWPFParagraph note = text(document, "Note");
+ DocxDrawingAnchors anchors = anchors();
+ anchors.paragraphOn(0, caption, true);
+ anchors.seat(0, entry, "Flow[0]/Card[1]/Entry[0]", entry, 110, 290, 100, 110);
+ anchors.seat(0, caption, "Flow[0]/Card[1]/Caption[1]", caption, 40, 300, 160, 170);
+ anchors.seat(0, note, "Flow[0]/Aside[2]/Note[0]", note, 40, 300, 106, 116);
+
+ queue(anchors, "Flow[0]/Card[1]/Icon[2]", List.of(dot(120, 102)));
+ anchors.endSection(0, null, document::createParagraph);
+
+ assertThat(drawingsIn(entry)).isEmpty();
+ assertThat(drawingsIn(note)).isEmpty();
+ assertThat(offsetDown(drawingsIn(caption).get(0))).isCloseTo(-58, within(0.01));
+ }
+
+ @Test
+ void itsOwnCellMergedFromAboveLeavesItToTheParagraphsCellOutOfItsColumn() {
+ // As a timeline's dot in a column of its own, but that cell continues one from the row
+ // above: the text's cell keeps the dot, placed from its own column.
+ XWPFDocument document = new XWPFDocument();
+ XWPFTable table = document.createTable(1, 2);
+ XWPFTableCell dotCell = table.getRow(0).getCell(0);
+ XWPFTableCell textCell = table.getRow(0).getCell(1);
+ width(dotCell, 20);
+ width(textCell, 200);
+ margin(dotCell, 0);
+ margin(textCell, 0);
+ dotCell.getCTTc().getTcPr().addNewVMerge()
+ .setVal(org.openxmlformats.schemas.wordprocessingml.x2006.main.STMerge.CONTINUE);
+ XWPFParagraph entry = textCell.getParagraphs().get(0);
+ entry.createRun().setText("Entry");
+ DocxDrawingAnchors anchors = anchors();
+ anchors.paragraphOn(0, text(document, "Body"), true);
+ anchors.seat(0, entry, "Flow[0]/Row[1]/Text[1]", entry, 120, 320, 100, 110);
+
+ queue(anchors, "Flow[0]/Row[1]/Rail[0]/Dot[0]", List.of(dot(107, 102)));
+ anchors.endSection(0, null, document::createParagraph);
+
+ assertThat(drawingsIn(dotCell.getParagraphs().get(0))).isEmpty();
+ String drawing = drawingsIn(entry).get(0);
+ assertThat(drawing).contains("layoutInCell=\"1\"");
+ assertThat(offsetAcross(drawing)).as("left of the text's column").isCloseTo(-13, within(0.01));
+ }
+
@Test
void aCellHeldInACellSetToTheMiddleCarriesNoneEither() {
XWPFDocument document = new XWPFDocument();
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingsTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingsTest.java
index ad23c6fb6..5ce17f540 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingsTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingsTest.java
@@ -629,6 +629,33 @@ void aFirstPageLaidOutInATableGetsABodyParagraphBeforeItForItsShapes() throws Ex
}
}
+ @Test
+ void aRingAloneInItsColumnBesideALeftSetParagraphIsAnchoredInItsOwnCellWithNoHairline() throws Exception {
+ // The page above, its paragraph set from the left: the ring's own cell of the columns'
+ // row carries it, placed from that paragraph's top, and nothing opens the page before
+ // the table.
+ try (XWPFDocument document = export(null, session -> session.pageFlow(page -> page
+ .addLayerStack(stack -> stack
+ .layer(column("Side", 0, 260, side -> side.addParagraph(p -> p.text("Contact"))),
+ com.demcha.compose.document.node.LayerAlign.TOP_LEFT)
+ .layer(column("Main", 100, 0, main -> main
+ .addEllipse(e -> e.name("Ring").circle(40).fillColor(ACCENT))),
+ com.demcha.compose.document.node.LayerAlign.TOP_LEFT))))) {
+ List body = document.getBodyElements();
+
+ assertThat(body.get(0)).as("the columns' row opens the page")
+ .isInstanceOf(org.apache.poi.xwpf.usermodel.XWPFTable.class);
+ List carriers = document.getTables().get(0).getRows().stream()
+ .flatMap(row -> row.getTableCells().stream())
+ .filter(cell -> cell.getCTTc().xmlText().contains("prst=\"ellipse\"")).toList();
+ assertThat(carriers).singleElement().satisfies(cell -> {
+ assertThat(cell.getText()).as("the ring's own cell, not the sidebar's").doesNotContain("Contact");
+ assertThat(anchors(cell.getCTTc().xmlText()).get(0)).contains("layoutInCell=\"1\"")
+ .contains("");
+ });
+ }
+ }
+
@Test
void aShapeInAPaintedPanelIsDrawnInFrontOfTheText() throws Exception {
// Both editors paint a shaded cell over what lies behind the text.
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphReportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphReportTest.java
index 05cd9d513..108f00dd4 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphReportTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxParagraphReportTest.java
@@ -152,7 +152,11 @@ void aPrefixIsNamedWhereThePathThatWritesTheParagraphWritesNone() throws Excepti
.isEqualTo("written as one side of a line it shares; its bulletOffset's letters, \"•\", are not "
+ "written before its first line; " + room);
// A text box over the flow.
- String boxed = "laid over the flow, which gives it no room: set in a text box where the page sets it";
+ String boxed = "laid over the flow, which gives it no room: set in a text box where the page sets it, "
+ + "anchored in the first paragraph able to carry it that the nearest block holding it writes "
+ + "on its page, so it moves with that block when the text above is edited; in the paragraph "
+ + "nearest it where no block holding it writes one there, or to the page where no paragraph "
+ + "on its page can carry it";
assertThat(paragraphNotes(page -> page.add(sidebar(new ParagraphBuilder().name("Monogram").text("L")
.bulletOffset("• ").indentStrategy(DocumentTextIndent.FIRST_LINE).build())).addParagraph("Masthead")))
.contains(boxed + "; its bulletOffset's letters, \"•\", are not written before its first line; " + room);
From 6ac5892b77f06ea3fb6cc53bb3d1295fb406ebc0 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Tue, 27 Oct 2026 13:18:00 +0000
Subject: [PATCH 3/4] test(qa): take TealPulse's Linux LibreOffice rows from CI
Its email line stands in a cell set to the middle. It stood at +0.08pt
only while the contact separator was anchored in that cell, which made
LibreOffice 24.2 set the cell from its top. A cell set to the middle now
carries no drawing, and LibreOffice centres the cell as it does the rest
of its row: +0.93pt, where Word sets the line at +1.14pt.
The document's rows come from CI run 40503780286. Ten other lines stand
0.1 to 0.3pt further, as the same run sets lines of documents the export
does not change; no other document's rows are taken.
---
.../docx-fidelity/libreoffice-linux-lines.tsv | 91 ++++++++++---------
.../docx-fidelity/libreoffice-linux.tsv | 3 +-
2 files changed, 48 insertions(+), 46 deletions(-)
diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv
index 2714dd32e..fd32e9832 100644
--- a/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv
+++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv
@@ -2,6 +2,7 @@
# rows of invoice-metered_long, invoice-obsidian_long, synthetic-break_in_column, synthetic-break_in_panel, synthetic-card_over_pages: libreoffice build 420(Build:2) on linux
# rows of synthetic-clipped_container, synthetic-narrow_container, synthetic-rounded_card: libreoffice build 420(Build:2) on linux
# rows of synthetic-icon_over_card: libreoffice build 420(Build:2) on linux
+# rows of cv-teal_pulse: libreoffice build 420(Build:2) on linux, from CI run 40503780286. Its email line, set in a cell set to the middle, stood +0.08pt while the contact separator was anchored in that cell, LibreOffice 24.2 then setting the cell from its top; the separator now goes elsewhere, as a cell set to the middle carries no drawing, and LibreOffice centres the cell as its neighbours: +0.93pt, Word +1.14pt. Ten other lines stand 0.1 to 0.3pt further, as the same run sets lines of documents the export does not change
cv-blue_banner 0:8f1010120ed6 0.02 1.90 jordanrivera
cv-blue_banner 0:8d7a2cca3060 0.00 0.14 +442055551000|jordan.rivera@exam
cv-blue_banner 0:b944906ed84c -0.20 -0.38 professionalsummary
@@ -1487,60 +1488,60 @@ cv-slate_orange 0:07ef0ade6bb2 0.05 0.07 •servsafefoodhandlercertificatio
cv-teal_pulse 0:a5800de7a5df -1.87 0.06 isabellamoore
cv-teal_pulse 0:aea71faf1482 -0.38 0.06 registerednurse
cv-teal_pulse 0:613c7b77c405 1.08 0.17 manchester,unitedkingdom
-cv-teal_pulse 0:4ddba42ce115 0.08 0.14 isabella.moore@email.com
+cv-teal_pulse 0:4ddba42ce115 0.93 0.14 isabella.moore@email.com
cv-teal_pulse 0:a8acc3a90a7b 0.68 0.17 +447700900123
cv-teal_pulse 0:84308bea4540 0.83 0.16 linkedin
cv-teal_pulse 0:32c1cf49a2fe 0.15 0.10 skills
-cv-teal_pulse 0:9836c1fde1ab -0.09 0.08 patientassessment
-cv-teal_pulse 0:7344802265f6 -0.11 0.08 careplanning
-cv-teal_pulse 0:32b1d161b612 -0.13 0.08 medicationadministration
-cv-teal_pulse 0:b9fff349f0ab -0.15 0.08 clinicaldocumentation
-cv-teal_pulse 0:cc981996b30d -0.16 0.08 infectionprevention
-cv-teal_pulse 0:48d90339937f -0.18 0.08 ivtherapy
-cv-teal_pulse 0:a392ecf06df5 -0.20 0.08 woundcare
-cv-teal_pulse 0:7016cd40c2ef -0.21 0.08 patienteducation
-cv-teal_pulse 0:ff0a6cdd9508 -0.23 0.08 safeguarding
-cv-teal_pulse 0:7b6511893d61 -0.25 0.08 teamcollaboration
-cv-teal_pulse 0:d9ff32cb0736 -0.26 0.08 dischargecoordination
-cv-teal_pulse 0:ceb75137b9fb -0.28 0.08 electronichealthrecords
-cv-teal_pulse 0:b944906ed84c -0.30 0.08 professionalsummary
-cv-teal_pulse 0:f8343b9099ef 0.65 0.06 compassionateregisterednursewith
-cv-teal_pulse 0:50347a4f84fc 0.64 0.06 high-qualitypatientcareacrossacu
-cv-teal_pulse 0:d24f5be95f6a 0.63 0.06 settings.skilledinpatientassessm
-cv-teal_pulse 0:824bf3935437 0.62 0.06 multidisciplinarycollaboration,d
-cv-teal_pulse 0:8d526cd52034 0.60 0.06 accurateclinicaldocumentation.kn
-cv-teal_pulse 0:4d61455b351b 0.59 0.06 communication,andpatient-centred
-cv-teal_pulse 0:c2f296ef30a3 -0.35 0.08 professionalexperience
+cv-teal_pulse 0:9836c1fde1ab 0.21 0.08 patientassessment
+cv-teal_pulse 0:7344802265f6 0.19 0.08 careplanning
+cv-teal_pulse 0:32b1d161b612 0.17 0.08 medicationadministration
+cv-teal_pulse 0:b9fff349f0ab 0.15 0.08 clinicaldocumentation
+cv-teal_pulse 0:cc981996b30d 0.14 0.08 infectionprevention
+cv-teal_pulse 0:48d90339937f 0.12 0.08 ivtherapy
+cv-teal_pulse 0:a392ecf06df5 0.10 0.08 woundcare
+cv-teal_pulse 0:7016cd40c2ef 0.09 0.08 patienteducation
+cv-teal_pulse 0:ff0a6cdd9508 0.07 0.08 safeguarding
+cv-teal_pulse 0:7b6511893d61 0.05 0.08 teamcollaboration
+cv-teal_pulse 0:d9ff32cb0736 0.04 0.08 dischargecoordination
+cv-teal_pulse 0:ceb75137b9fb 0.02 0.08 electronichealthrecords
+cv-teal_pulse 0:b944906ed84c 0.25 0.08 professionalsummary
+cv-teal_pulse 0:f8343b9099ef 0.15 0.06 compassionateregisterednursewith
+cv-teal_pulse 0:50347a4f84fc 0.14 0.06 high-qualitypatientcareacrossacu
+cv-teal_pulse 0:d24f5be95f6a 0.13 0.06 settings.skilledinpatientassessm
+cv-teal_pulse 0:824bf3935437 0.12 0.06 multidisciplinarycollaboration,d
+cv-teal_pulse 0:8d526cd52034 0.10 0.06 accurateclinicaldocumentation.kn
+cv-teal_pulse 0:4d61455b351b 0.09 0.06 communication,andpatient-centred
+cv-teal_pulse 0:c2f296ef30a3 0.20 0.08 professionalexperience
cv-teal_pulse 0:5cad9261fd60 0.14 0.06 seniorstaffnurse—manchesterroyal
-cv-teal_pulse 0:5149e8e5e6b6 -0.08 0.05 deliverdirectnursingcareforadult
-cv-teal_pulse 0:8dcc0d173718 -0.08 0.05 coordinatecareplanswithdoctors,t
-cv-teal_pulse 0:325626fb2f7a -0.09 0.05 supervisejuniornursesandsupports
-cv-teal_pulse 0:c2864de08768 -0.09 0.05 improveddischargecoordinationand
-cv-teal_pulse 0:3ac4bbda9488 -0.10 0.05 maintainprecisedocumentationande
+cv-teal_pulse 0:5149e8e5e6b6 0.22 0.05 deliverdirectnursingcareforadult
+cv-teal_pulse 0:8dcc0d173718 0.22 0.05 coordinatecareplanswithdoctors,t
+cv-teal_pulse 0:325626fb2f7a 0.21 0.05 supervisejuniornursesandsupports
+cv-teal_pulse 0:c2864de08768 0.21 0.05 improveddischargecoordinationand
+cv-teal_pulse 0:3ac4bbda9488 0.20 0.05 maintainprecisedocumentationande
cv-teal_pulse 0:59a7aa760eea 0.13 0.06 staffnurse—salfordgeneralhospita
-cv-teal_pulse 0:99bff63b0b23 -0.09 0.05 providednursingcareacrossmedical
-cv-teal_pulse 0:dee1c05c380e -0.09 0.05 administeredmedications,monitore
-cv-teal_pulse 0:a0cc1403feda -0.10 0.05 supportedinfectioncontrolprocedu
-cv-teal_pulse 0:25239222c011 -0.20 0.05 builtstrongrapportwithpatientsan
+cv-teal_pulse 0:99bff63b0b23 0.21 0.05 providednursingcareacrossmedical
+cv-teal_pulse 0:dee1c05c380e 0.21 0.05 administeredmedications,monitore
+cv-teal_pulse 0:a0cc1403feda 0.20 0.05 supportedinfectioncontrolprocedu
+cv-teal_pulse 0:25239222c011 0.10 0.05 builtstrongrapportwithpatientsan
cv-teal_pulse 0:f25d4331b19b 0.02 0.06 communitynurse—northcarecommunit
-cv-teal_pulse 0:19eefb6b6368 -0.19 0.05 deliveredhome-basednursingcarean
-cv-teal_pulse 0:16412b3fd5d8 -0.20 0.05 managedwoundcare,medicationsuppo
-cv-teal_pulse 0:fb42708d13d3 -0.20 0.05 workedcloselywithgps,socialservi
-cv-teal_pulse 0:68bf0109c6f8 -0.31 0.11 education
+cv-teal_pulse 0:19eefb6b6368 0.11 0.05 deliveredhome-basednursingcarean
+cv-teal_pulse 0:16412b3fd5d8 0.10 0.05 managedwoundcare,medicationsuppo
+cv-teal_pulse 0:fb42708d13d3 0.10 0.05 workedcloselywithgps,socialservi
+cv-teal_pulse 0:68bf0109c6f8 0.09 0.11 education
cv-teal_pulse 0:c1596da36d69 0.04 0.10 bsc(hons)adultnursing
-cv-teal_pulse 0:47ce0218bbea -0.02 0.10 universityofmanchester
-cv-teal_pulse 0:1da060005bfc -0.25 0.10 2013–2016
-cv-teal_pulse 0:e831beb04c00 -0.31 0.06 certifications
-cv-teal_pulse 0:ec8bd1e014bb -0.24 0.04 nmcregisterednurse
-cv-teal_pulse 0:669f27afcccf -0.27 0.04 immediatelifesupport(ils)
-cv-teal_pulse 0:34b32be15830 -0.30 0.04 venepunctureandcannulationcertif
-cv-teal_pulse 0:32215e12a886 -0.33 0.04 safeguardingadultslevel3
-cv-teal_pulse 0:676dfdd56ad2 -0.31 0.08 additionalinformation
+cv-teal_pulse 0:47ce0218bbea 0.03 0.10 universityofmanchester
+cv-teal_pulse 0:1da060005bfc 0.00 0.10 2013–2016
+cv-teal_pulse 0:e831beb04c00 0.09 0.06 certifications
+cv-teal_pulse 0:ec8bd1e014bb 0.06 0.04 nmcregisterednurse
+cv-teal_pulse 0:669f27afcccf 0.03 0.04 immediatelifesupport(ils)
+cv-teal_pulse 0:34b32be15830 0.00 0.04 venepunctureandcannulationcertif
+cv-teal_pulse 0:32215e12a886 -0.03 0.04 safeguardingadultslevel3
+cv-teal_pulse 0:676dfdd56ad2 0.09 0.08 additionalinformation
cv-teal_pulse 0:bcc890a69aa7 0.06 0.07 languages:english(native),
cv-teal_pulse 0:de15ca9e3d9f 0.03 0.07 polish(conversational)
-cv-teal_pulse 0:b035d3696f4e -0.02 0.07 righttowork:unitedkingdom
-cv-teal_pulse 0:cfa1d6dd779c -0.17 0.07 availability:1monthnotice
-cv-teal_pulse 0:b9b8965717b3 -0.28 1.62 compassionatecare.clinicalexcell
+cv-teal_pulse 0:b035d3696f4e 0.03 0.07 righttowork:unitedkingdom
+cv-teal_pulse 0:cfa1d6dd779c 0.03 0.07 availability:1monthnotice
+cv-teal_pulse 0:b9b8965717b3 0.07 1.62 compassionatecare.clinicalexcell
cv-terracotta_rail 0:920a88ef5818 0.10 0.12 oliver.bennett@email.com
cv-terracotta_rail 0:a8acc3a90a7b 0.10 0.12 +447700900123
cv-terracotta_rail 0:e8c463a6c54d 0.10 0.12 bristol,unitedkingdom
diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv
index cf14305c8..cc8d69ca8 100644
--- a/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv
+++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv
@@ -3,6 +3,7 @@ document engine_pages editor_pages lines matched median p90 over2
# rows of invoice-metered_long, invoice-obsidian_long, synthetic-break_in_column, synthetic-break_in_panel, synthetic-card_over_pages: libreoffice build 420(Build:2) on linux
# rows of synthetic-clipped_container, synthetic-narrow_container, synthetic-rounded_card: libreoffice build 420(Build:2) on linux
# rows of synthetic-icon_over_card: libreoffice build 420(Build:2) on linux
+# rows of cv-teal_pulse: libreoffice build 420(Build:2) on linux, from CI run 40503780286. Its email line, set in a cell set to the middle, stood +0.08pt while the contact separator was anchored in that cell, LibreOffice 24.2 then setting the cell from its top; the separator now goes elsewhere, as a cell set to the middle carries no drawing, and LibreOffice centres the cell as its neighbours: +0.93pt, Word +1.14pt. Ten other lines stand 0.1 to 0.3pt further, as the same run sets lines of documents the export does not change
cv-blue_banner 2 2 54 52 0.16 0.25 0
cv-boxed_sections 2 2 58 58 0.06 0.11 0
cv-centered_headline 2 2 58 56 0.12 0.29 0
@@ -25,7 +26,7 @@ cv-professional_sidebar 1 1 68 64 0.20 0.28 0
cv-serif_headline 1 1 103 103 0.19 0.59 0
cv-sidebar_portrait 1 1 49 49 0.07 0.18 0
cv-slate_orange 1 1 87 86 0.23 0.99 0
-cv-teal_pulse 1 1 57 57 0.20 0.63 0
+cv-teal_pulse 1 1 57 57 0.12 0.25 0
cv-terracotta_rail 1 1 102 102 0.17 0.52 0
cv-timeline_minimal 2 2 89 87 0.05 0.11 0
cv-violet_grid 1 1 80 78 0.27 0.59 0
From da00105b98e94876438ab3e427b674822bb09695 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Tue, 27 Oct 2026 13:19:07 +0000
Subject: [PATCH 4/4] test(docx): report an offset that is not a number as a
failed assertion
The owner-anchor test reads drawing offsets from the written XML; one that
is not a whole number of EMU now fails the test with what was found rather
than throwing, as the drawing-anchors test already does.
---
.../backend/semantic/docx/DocxDrawingOwnerAnchorsTest.java | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingOwnerAnchorsTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingOwnerAnchorsTest.java
index 4a92f6e81..48f8b7440 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingOwnerAnchorsTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxDrawingOwnerAnchorsTest.java
@@ -500,7 +500,11 @@ private static double offset(String drawing, String axis) {
Matcher matcher = Pattern.compile("(-?\\d+)<")
.matcher(drawing);
assertThat(matcher.find()).isTrue();
- return Long.parseLong(matcher.group(1)) / 12700.0;
+ try {
+ return Long.parseLong(matcher.group(1)) / 12700.0;
+ } catch (NumberFormatException notAnOffset) {
+ throw new AssertionError("not an offset in EMU: " + matcher.group(1), notAnOffset);
+ }
}
private static long emu(double points) {