From 9abe6d9db3e2225d11ab9e85a05d15bc3daf79da Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Thu, 29 Oct 2026 19:21:52 +0000
Subject: [PATCH 1/5] fix(docx): let a list's items and a shared line carry
their block's drawings
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A paragraph was offered to the drawings standing beside it only while a paragraph node was
being written, so a block whose text another writer writes had no paragraph to offer at all:
a card whose only text is a list's items, a CV entry's head band written as one Word line.
Their drawings fell back to the text nearest them — in the body, the paragraph above the card,
and a drawing is placed from that paragraph's top. Lengthen that paragraph by a line and the
card moves while the drawing stays: measured in Word 16.0.20527 and LibreOffice 24.2 on a probe
of each case, the card's own text moved 9.72pt and 19.40pt and the drawing 0.00 in both.
offerASeat now takes the piece of text, the path of its node and that text's box, and three
writers call it: a paragraph node's own paragraph, as before; each item of a list, with that
item's box, which DocxLayoutMetrics.ItemText now carries (built by the same reckoning as
firstTextBox, extracted as textBoxOf); and the one line two halves of a band share, offered as
the half that starts it, since both are written in that one paragraph and a seat stands for a
paragraph. An item offers its place where the export matched the layout's items to the list's one
by one, which their line gap and their own seating need as well.
Carried so, the drawing moves with its card — 9.70pt in Word and 19.35pt in LibreOffice — and
stands on its corner, 0.09pt from where the page draws it against 13.76pt before.
A table's own cell text offers none, and the drawings whose block's text is written only so keep
the nearest-text rule: a table's cells state no width of their own, their table's grid holding it,
and a drawing anchored in a cell is placed from the cell's text column, which cannot be read
without that width; a text cell is set to the middle by default besides, and such a cell carries no
drawing at all. The corpus has 1811 cells stating no width of their own, 1713 of them set to the
middle, so a place offered for them could be taken in no document of it. A test holds that limit,
and the recipe, the capability matrix and the CHANGELOG name it.
Corpus of 71 documents: drawings placed from the page's edges 39 to 32 — all six of
invoice-luma_studio's are anchored in their holders, and the hairline paragraph its section
opened to carry them is no longer written. Six documents changed, 135 paragraphs; notes 1497 to
1497. The Word fidelity baseline passes unchanged, and across the six changed documents every
drawing stands within 0.30pt of its previous place in Word, 2821 of 2833 of them identical: the
largest move is six dots in two CVs 0.25 to 0.30pt higher, as far as Word sets the shared line
they now stand by below the page.
DocxTextWriterAnchorTest holds the rule from both sides, and a sabotage of each of its five
parts was caught.
---
CHANGELOG.md | 24 ++
.../architecture/backend-capability-matrix.md | 2 +-
docs/recipes/docx-export.md | 18 +-
.../semantic/docx/DocxLayoutMetrics.java | 39 ++-
.../semantic/docx/DocxSemanticBackend.java | 71 ++++-
.../docx/DocxTextWriterAnchorTest.java | 268 ++++++++++++++++++
6 files changed, 402 insertions(+), 20 deletions(-)
create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextWriterAnchorTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4cb0dc5dd..6cdde34d9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,30 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A DOCX export anchors a drawing in a paragraph written by any writer of text — a list's item,
+ the one line two halves of a band share — so a card whose only text is written so moves its
+ drawings with it when the text above is edited.**
+ - A paragraph was offered to the drawings standing beside it only while a paragraph node was
+ being written, so a block whose text another writer writes had no paragraph to offer: a card
+ whose only text is a list's items, and a CV entry's head band written as one Word line. Their
+ drawings went to the text nearest them — in the body, the paragraph above the card, whose top
+ does not move when that paragraph grows a line and the card does.
+ - Measured in Word 16.0.20527 and LibreOffice 24.2, with a paragraph above lengthened by one
+ line: the card's own text moved 9.72pt in Word and 19.40pt in LibreOffice, and the drawing
+ moved 0.00 in both. Carried by the item or by the shared line, it moves with them — 9.70 and
+ 19.35pt — and stands on the card's corner where the page draws it, 0.09pt from it in Word
+ against 13.76pt before.
+ - Each item of a list is offered with its own text's box, so a drawing standing beside the third
+ item is anchored in that item — where the export matched the layout's items to the list's one
+ by one, which the items' line gap and their own seating need as well; a list with an item split
+ across a page is laid out as more pieces of text than it has items, and offers none. A band's
+ shared line is offered as the half that starts it: both halves are written in that one
+ paragraph, and that half's text begins where the line does.
+ - A table's own cell text offers no place yet, and a drawing whose block's text is written only
+ so keeps the nearest-text rule: a table's cells state no width of their own — their table's
+ grid holds it — and a drawing anchored in a cell is placed from the cell's text column, which
+ cannot be read without it; a text cell is set to the middle by default besides, and such a
+ cell carries no drawing.
- **A DOCX export anchors what a table's composed cell draws in that cell, so it moves with the
cell's row when the text above is edited.**
- Such a drawing has no node of its own — a composed cell's content is laid out under its
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index f5c74a5ba..19bbb1877 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -46,7 +46,7 @@ nodes are a different case: `ShapeNode`, `LineNode`, `EllipseNode`,
`DocxSemanticBackend.writeNode`. A horizontal `LineNode` or a thin filled
`ShapeNode` is written as a rule; any other rectangle, rounded rectangle,
ellipse, line, polygon or path is drawn from the fragments the layout placed
-it as, a floating DrawingML shape behind the text, anchored in the first paragraph the nearest block holding it writes on its page — the paragraph nearest it where none does, the first paragraph of the cell whose box holds it for what a composed cell draws — or else to the page (`DocxDrawings`, `DocxDrawingAnchors`,
+it as, a floating DrawingML shape behind the text, anchored in the first paragraph the nearest block holding it writes on its page, whichever of three writers wrote that paragraph — a paragraph node's own, an item of a list the layout's items are matched to, the one line two halves of a band share; not a table's own cell text, which states no width for a shape to be placed from — the paragraph nearest it where none does, the first paragraph of the cell whose box holds it for what a composed cell draws — or else to the page (`DocxDrawings`, `DocxDrawingAnchors`,
polygons and paths as custom geometry). Inside a filled panel a shape stands in
front of the text unless it frames text; anchored in a paragraph of the panel's
cell, it is placed from the cell's text column, as Word lays a shape anchored in a
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 4dfa29e8d..b58b213f9 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -1517,10 +1517,20 @@ The paragraph's top is where Word places from: the space written above its first
included, and the line where Word stands it — an exact line's baseline four fifths of the way
down it, raised by the run's position — measured in Word 16.0.20430 and LibreOffice to a tenth
of a point; a paragraph with a border above its line, contextual spacing or space reckoned in
-lines carries none. Only the first paragraph holding text that a paragraph node is written as
-offers its place, in a column's cell too; a table's own cell text, a list's items and a pair
-of lines do not yet, so a shape whose block's text is written only so goes to the paragraph
-nearest it.
+lines carries none. Three writers of text offer their paragraphs, and the first
+paragraph of each piece of text that holds text takes the place: a paragraph node's own
+paragraph, in a column's cell too; each item of a list, so that a card whose only text is a
+list's items carries the drawings laid over it, and a shape standing beside the third item takes
+that item; and the one line two halves of a band share — a title at the left, its dates at the
+right — offered as the half that starts the line, since both are written in that one paragraph.
+An item offers its place only where the export matched the layout's items to the list's one by
+one, which its line gap and its own seating need as well: a list with an item split across a
+page is laid out as more pieces of text than it has items, and none of its items offers a place.
+A table's own cell text offers none yet either: a table's cells state no width of their own,
+their table's grid holding it, and a shape anchored in a cell is placed from the cell's text
+column, which cannot be read without that width; a text cell is also set to the middle by
+default, and such a cell carries no drawing (above). So a shape whose block's text is written
+only as a table's cells goes to the paragraph nearest it.
What a table's composed cell draws has no node of its own: a composed cell's content is laid out
under its table, and its drawings stand among the table's own fragments. The cell whose box the
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 e69cda22e..7e492901d 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
@@ -589,7 +589,7 @@ List itemLines(DocumentNode list) {
}
boolean sameItem = previous != null && level(previous, fragment);
ItemText text = new ItemText(paragraph.lines(), fragment.x() + paragraph.padding().left(),
- fragment.pageIndex(), Math.max(0, paragraph.lineGap()));
+ fragment.pageIndex(), Math.max(0, paragraph.lineGap()), textBoxOf(fragment, paragraph));
if (!sameItem) {
items.add(text);
} else if (text.x() >= items.get(items.size() - 1).x()) {
@@ -604,11 +604,15 @@ List itemLines(DocumentNode list) {
* An item's text as a list laid it out.
*
* @param lines its lines
- * @param x where its text starts across the page, in points
+ * @param x where its text starts across the page, in points — the box's left edge as
+ * well, held apart from it because an item the page laid no line of has no box
* @param page the page it was set on
* @param lineGap the gap the page sets between its lines, in points
+ * @param box where the page sets that same text, which the item's paragraph is offered to
+ * the shapes beside with ({@code DocxSemanticBackend.offerASeat}); {@code null}
+ * for an item the page laid no line of, which has no text to stand beside
*/
- record ItemText(List lines, double x, int page, double lineGap) {
+ record ItemText(List lines, double x, int page, double lineGap, TextBox box) {
/**
* How tall the page sets the item's lines together, as {@link #linesHeight(DocumentNode)}
@@ -1188,18 +1192,33 @@ OptionalDouble firstLineTop(DocumentNode node) {
*/
java.util.Optional firstTextBox(DocumentNode node) {
for (PlacedFragment fragment : textFragmentsOf(node)) {
- if (fragment.payload() instanceof ParagraphFragmentPayload paragraph && !paragraph.lines().isEmpty()) {
- double top = ParagraphLineGeometry.contentTop(fragment.y(), fragment.height(), paragraph.padding().top());
- ParagraphLine first = paragraph.lines().get(0);
- return java.util.Optional.of(new TextBox(fragment.pageIndex(),
- fragment.x() + paragraph.padding().left(),
- fragment.x() + fragment.width() - paragraph.padding().right(),
- top, top - first.lineHeight() + first.baselineOffsetFromBottom()));
+ if (fragment.payload() instanceof ParagraphFragmentPayload paragraph) {
+ TextBox box = textBoxOf(fragment, paragraph);
+ if (box != null) {
+ return java.util.Optional.of(box);
+ }
}
}
return java.util.Optional.empty();
}
+ /**
+ * Where the page sets the lines of one fragment of paragraph text: its content box, inside the
+ * paragraph's padding, and its first line's baseline. {@code null} for a fragment holding no
+ * line.
+ */
+ private static TextBox textBoxOf(PlacedFragment fragment, ParagraphFragmentPayload paragraph) {
+ if (paragraph.lines().isEmpty()) {
+ return null;
+ }
+ double top = ParagraphLineGeometry.contentTop(fragment.y(), fragment.height(), paragraph.padding().top());
+ ParagraphLine first = paragraph.lines().get(0);
+ return new TextBox(fragment.pageIndex(),
+ fragment.x() + paragraph.padding().left(),
+ fragment.x() + fragment.width() - paragraph.padding().right(),
+ top, top - first.lineHeight() + first.baselineOffsetFromBottom());
+ }
+
/**
* The content box of a node's first fragment of text, and its first line's baseline.
*
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 d4599a2e7..4bbdd186d 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
@@ -877,6 +877,11 @@ private int startPageOf(DocumentNode node) {
/** Where the list being written starts its items on the page, in points; NaN unknown. */
private double listTextLeft = Double.NaN;
+ /**
+ * The path of the list being written, which each of its items is offered to the shapes beside
+ * it with ({@link #seatTheItem}); {@code null} outside a list.
+ */
+ private String listPath;
/** Whether the layout's items are matched to the list's, one by one: each then has its place. */
private boolean listItemsMatched;
/**
@@ -5760,6 +5765,9 @@ private void writeList(XWPFDocument document,
double previousTextLeft = listTextLeft;
com.demcha.compose.document.layout.PlacedNode listBox = layout.placement(list);
listTextLeft = listBox == null ? Double.NaN : listBox.placementX() + list.padding().left();
+ // Whose items the shapes beside them are offered (seatTheItem).
+ String previousPath = listPath;
+ listPath = layout.pathOf(list);
// Its own sides hold its items in, as a paragraph's hold its text (see writeParagraph):
// NavySidebar indents its closing lists to clear the badge beside their heading, and
// without it their markers stood under the badge, the text a line short in Word.
@@ -5802,6 +5810,7 @@ private void writeList(XWPFDocument document,
itemsLowered = previousLowered;
listItemLines = previousItemLines;
listTextLeft = previousTextLeft;
+ listPath = previousPath;
listItemsMatched = previousMatched;
listItemsWritten = previousWritten;
}
@@ -6379,6 +6388,11 @@ private void seatTheItem(XWPFParagraph para, DocxLayoutMetrics.ItemText laidOut,
if (laidOut == null) {
return;
}
+ // Each item is a piece of text of its own, offered to the shapes beside it: a shape the
+ // list or a block round it holds takes the first item written on its page.
+ if (laidOut.box() != null && listPath != null) {
+ offerASeat(para, laidOut, listPath, laidOut.box(), 0);
+ }
boolean opensItsPage = itemPage < 0 ? listOpensItsPage : laidOut.page() > itemPage;
itemPage = laidOut.page();
boolean staysOnItsPage = !opensItsPage && (currentCell == null || listOnOnePage || inARowKeptWhole());
@@ -9871,13 +9885,51 @@ private XWPFParagraph newBodyParagraph(XWPFDocument document) {
* first of them that holds text takes the place.
*/
private void offerASeat(XWPFParagraph para) {
- if (!(writing instanceof ParagraphNode node) || Double.isNaN(canvasHeight)) {
+ if (writing instanceof ParagraphNode node) {
+ offerASeatFor(para, node);
+ }
+ }
+
+ /**
+ * Offers a paragraph to the shapes beside the text of one paragraph node written in it, with
+ * the baseline the page draws its first line on, seated as the page seats it.
+ */
+ private void offerASeatFor(XWPFParagraph para, ParagraphNode node) {
+ layout.firstTextBox(node).ifPresent(box -> offerASeat(para, node, layout.pathOf(node), box, seatShift(node)));
+ }
+
+ /**
+ * Offers a paragraph written for any piece of text to the shapes standing beside it: a
+ * paragraph node's text, an item of a list whose items the layout's are matched to
+ * ({@link #listItemsMatched}), the line two halves of a band share.
+ *
+ * A shape belongs to the node drawing it and to the blocks holding that node, and is
+ * anchored in the first paragraph the nearest of them writes on its page
+ * ({@link DocxDrawingAnchors}). A block whose text another writer writes had no paragraph to
+ * offer: a card whose only text is a list's items, a CV entry's head band written as one Word
+ * line. Their shapes went to the text nearest them instead — in the body, the paragraph above
+ * the card, whose top does not move when that paragraph grows a line and the card does.
+ * Measured in Word 16.0.20527 and LibreOffice 24.2 on a probe of each, with the paragraph above
+ * lengthened by a line: the card's own text moved 9.72pt in Word and 19.40pt in LibreOffice,
+ * and the shape 0.00 in both; carried by the item or by the shared line it moves with them,
+ * 9.70 and 19.35pt, and stands on the card's corner, 0.09pt from it in Word against 13.76pt
+ * before.
+ *
+ * @param owner the piece of text the paragraph was written for: the first paragraph of it that
+ * holds text takes the place, so each item and each shared line is its own
+ * @param path the path of the node whose text it is, which a shape's holders are matched
+ * against; a list's own for each of its items
+ * @param box where the page sets that text, y up from the foot of the page
+ * @param seated how far the page moves it off its baseline to seat it ({@link #seatShift}), 0
+ * where the page sets it on its baseline
+ */
+ private void offerASeat(XWPFParagraph para, Object owner, String path,
+ DocxLayoutMetrics.TextBox box, double seated) {
+ if (Double.isNaN(canvasHeight)) {
return;
}
- // 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, layout.pathOf(node), para, box.left(), box.right(),
- canvasHeight - box.top(), canvasHeight - box.baseline() - seatShift(node)));
+ anchors.seat(box.page(), owner, path, para, box.left(), box.right(),
+ canvasHeight - box.top(), canvasHeight - box.baseline() - seated);
}
/**
@@ -11151,6 +11203,15 @@ private void writeLinePair(XWPFDocument document, DocumentNode overlay, DocxLine
int rightAnchor = openAnchor(para, pair.right().anchor());
writeParagraphRuns(para, pair.right(), false, lineTopAbove(lineTop, pair.right()), false);
closeAnchor(para, rightAnchor);
+ // The line the two halves share is offered to the shapes beside it, as the text of the
+ // half that starts it: a shape the overlay or a block round it holds takes that line. One
+ // seat is enough for it, since both halves are written in this one paragraph and a seat
+ // stands for a paragraph; the half that starts the line is the one whose text begins where
+ // the line begins, and whose raise is the one Word reads back from the line's first run
+ // (DocxDrawingAnchors.Seat#paragraphTop). The line itself is raised by the lesser of what
+ // its two halves call for (raiseTheSharedLine), so a shape placed from it stands as far
+ // below the page's place as the line does: 0.2 to 0.33pt, measured there.
+ offerASeatFor(para, pair.left());
raiseTheSharedLine(para, pair, lineTop, opensItsPage);
// A Word paragraph has one outline level, and its outline lists the whole line by it: the
// left side's level where it has one, the right side's where not; the other's is lost.
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextWriterAnchorTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextWriterAnchorTest.java
new file mode 100644
index 000000000..a45e1c5ca
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextWriterAnchorTest.java
@@ -0,0 +1,268 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import com.demcha.compose.document.dsl.ParagraphBuilder;
+import com.demcha.compose.document.dsl.SectionBuilder;
+import com.demcha.compose.document.node.DocumentNode;
+import com.demcha.compose.document.node.EllipseNode;
+import com.demcha.compose.document.node.LayerAlign;
+import com.demcha.compose.document.node.TextAlign;
+import com.demcha.compose.document.style.ClipPolicy;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.document.table.DocumentTableCell;
+import com.demcha.compose.document.table.DocumentTableColumn;
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.apache.poi.xwpf.usermodel.XWPFParagraph;
+import org.apache.poi.xwpf.usermodel.XWPFTableCell;
+import org.apache.xmlbeans.XmlObject;
+import org.junit.jupiter.api.Test;
+import org.openxmlformats.schemas.drawingml.x2006.wordprocessingDrawing.CTAnchor;
+import org.openxmlformats.schemas.drawingml.x2006.wordprocessingDrawing.STRelFromV;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.within;
+
+/**
+ * A block whose text a writer of its own writes — a list's items, two halves sharing one line —
+ * carries the drawings of the blocks holding it.
+ *
+ * A paragraph was offered to the shapes beside it only when a paragraph node was being
+ * written, so a card whose only text was a list's items, and a CV entry's head band written as one
+ * line, had no paragraph to offer: their shapes went to the text nearest them, in the body the
+ * paragraph above the card, and stayed where that paragraph's top was while the card moved.
+ * Measured in Word 16.0.20527 and LibreOffice 24.2: a paragraph above lengthened by a line moved
+ * the card's text 9.72pt in Word and 19.40pt in LibreOffice and left the shape where it was; with
+ * the item carrying it, the shape moves with the card (9.70 and 19.35pt) and stands on its corner,
+ * 0.09pt from it in Word against 13.76pt before.
+ */
+class DocxTextWriterAnchorTest {
+
+ private static final DocumentColor INK = DocumentColor.rgb(20, 30, 40);
+ private static final DocumentColor SURFACE = DocumentColor.rgb(230, 240, 250);
+ private static final double DOT = 12;
+
+ @Test
+ void aCardWhoseTextIsAListsItemsCarriesTheDrawingLaidOverIt() throws Exception {
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> {
+ page.addParagraph(p -> p.text("Above the card"));
+ page.addLayerStack(stack -> stack
+ .layer(new SectionBuilder().softPanel(SURFACE, 0, 10)
+ .addList(list -> list.bullet().items("First item", "Second item")).build())
+ .layer(dot(), LayerAlign.TOP_RIGHT));
+ })) {
+ XWPFParagraph item = firstItem(document);
+
+ assertThat(placedFrom(item)).as("in the first item's paragraph, not on the page")
+ .containsExactly(STRelFromV.PARAGRAPH);
+ assertThat(placedFrom(document.getParagraphs().get(0))).as("not in the paragraph above")
+ .isEmpty();
+ assertThat(onThePage(document)).isZero();
+ // The card is a table cell, so the dot is placed from the cell's text column, which
+ // the item states by where its own text starts: the page lays the dot at the card's
+ // right edge, so it stands the cell's text width less its own width into that column.
+ XWPFTableCell card = document.getTables().get(0).getRow(0).getCell(0);
+ assertThat(offsetAcross(item)).as("from the cell's text column")
+ .isCloseTo(widthOf(card) - marginOf(card) - DOT, within(1.0));
+ }
+ }
+
+ @Test
+ void theFirstItemCarriesOneStandingBesideTheLast() throws Exception {
+ // The holder's first paragraph on the page takes it, however far from its text it stands:
+ // where it stands gives only its offset.
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> {
+ page.addParagraph(p -> p.text("Above the card"));
+ page.addLayerStack(stack -> stack
+ .layer(new SectionBuilder().softPanel(SURFACE, 0, 10)
+ .addList(list -> list.bullet().items("First item", "Second item")).build())
+ .layer(dot(), LayerAlign.BOTTOM_RIGHT));
+ })) {
+ XWPFParagraph item = firstItem(document);
+
+ assertThat(placedFrom(item)).containsExactly(STRelFromV.PARAGRAPH);
+ assertThat(offsetDown(item)).as("below the first item's top, where it stands").isPositive();
+ }
+ }
+
+ @Test
+ void theItemWhoseTextItStandsBesideCarriesOneNoBlockHolds() throws Exception {
+ // Each item is offered with its own text's box: a dot nothing holds, standing level with
+ // the third item and clear of the items' text across the page, is anchored in that item.
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> {
+ page.addList(list -> list.bullet().items("First item", "Second item", "Third item",
+ "Fourth item"));
+ page.addEllipse(dot -> dot.name("Dot").circle(10).fillColor(INK)
+ .margin(new DocumentInsets(-22, 0, 0, 250)));
+ })) {
+ List items = document.getParagraphs().stream()
+ .filter(para -> para.getCTP().getPPr() != null && para.getCTP().getPPr().isSetNumPr())
+ .toList();
+
+ assertThat(items).hasSize(4);
+ assertThat(placedFrom(items.get(2))).as("the third item, whose line it stands on")
+ .containsExactly(STRelFromV.PARAGRAPH);
+ assertThat(placedFrom(items.get(0))).as("not the first").isEmpty();
+ assertThat(onThePage(document)).isZero();
+ }
+ }
+
+ @Test
+ void theLineTwoHalvesShareCarriesTheDrawingsOfTheirBand() throws Exception {
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> {
+ page.addParagraph(p -> p.text("Above the band"));
+ page.addContainer(band -> band.name("EntryHead").rectangle(360, 16)
+ .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE)
+ .position(new ParagraphBuilder().name("Title").text("Delivery engineer")
+ .textStyle(DocumentTextStyle.DEFAULT.withSize(9)).build(), 0, 0, LayerAlign.CENTER_LEFT)
+ .position(new ParagraphBuilder().name("Dates").text("2019 - 2024").align(TextAlign.RIGHT)
+ .textStyle(DocumentTextStyle.DEFAULT.withSize(9)).build(), 0, 0, LayerAlign.CENTER_RIGHT)
+ .position(dot(), 160, 0, LayerAlign.CENTER_LEFT));
+ })) {
+ XWPFParagraph shared = shared(document);
+
+ assertThat(shared.getText()).isEqualTo("Delivery engineer\t2019 - 2024");
+ assertThat(placedFrom(shared)).as("in the line the halves share")
+ .containsExactly(STRelFromV.PARAGRAPH);
+ assertThat(onThePage(document)).isZero();
+ }
+ }
+
+ @Test
+ void theSharedLineCarriesOneNoBlockHoldsThatStandsBesideIt() throws Exception {
+ // A dot no block carries — nothing holding it writes text — goes to the paragraph whose
+ // text it stands nearest. The band's shared line is the only text on the page, and it is
+ // offered: without it the dot would be left to the page's edges.
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> {
+ page.addContainer(band -> band.name("EntryHead").rectangle(360, 16)
+ .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE)
+ .position(new ParagraphBuilder().name("Title").text("Engineer")
+ .textStyle(DocumentTextStyle.DEFAULT.withSize(9)).build(), 0, 0, LayerAlign.CENTER_LEFT)
+ .position(new ParagraphBuilder().name("Dates").text("2019 - 2024").align(TextAlign.RIGHT)
+ .textStyle(DocumentTextStyle.DEFAULT.withSize(9)).build(), 0, 0, LayerAlign.CENTER_RIGHT));
+ page.addEllipse(dot -> dot.name("Dot").circle(12).fillColor(INK)
+ .margin(new DocumentInsets(0, 0, 0, 290)));
+ })) {
+ XWPFParagraph shared = shared(document);
+
+ assertThat(placedFrom(shared)).as("the dot, beside the dates")
+ .containsExactly(STRelFromV.PARAGRAPH);
+ assertThat(onThePage(document)).isZero();
+ }
+ }
+
+ @Test
+ void aSharedLineInACellPlacesItFromTheCellsTextColumn() throws Exception {
+ // A shape anchored in a cell's paragraph is placed from the cell's text column, which the
+ // seat states by where its own text starts. A band inside a card is such a line, and the
+ // dot laid on it stands where the band places it, measured from that column.
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.addSection(
+ card -> card.softPanel(SURFACE, 0, 10).addContainer(band -> band.name("EntryHead")
+ .rectangle(320, 16).clipPolicy(ClipPolicy.OVERFLOW_VISIBLE)
+ .position(new ParagraphBuilder().name("Title").text("Engineer")
+ .textStyle(DocumentTextStyle.DEFAULT.withSize(9)).build(), 0, 0,
+ LayerAlign.CENTER_LEFT)
+ .position(new ParagraphBuilder().name("Dates").text("2019 - 2024").align(TextAlign.RIGHT)
+ .textStyle(DocumentTextStyle.DEFAULT.withSize(9)).build(), 0, 0,
+ LayerAlign.CENTER_RIGHT)
+ .position(dot(), 140, 0, LayerAlign.CENTER_LEFT))))) {
+ XWPFTableCell card = document.getTables().get(0).getRow(0).getCell(0);
+ XWPFParagraph shared = card.getParagraphs().stream()
+ .filter(para -> para.getText().contains("\t")).findFirst().orElseThrow();
+
+ assertThat(placedFrom(shared)).containsExactly(STRelFromV.PARAGRAPH);
+ assertThat(offsetAcross(shared)).as("from the cell's text column, where the line starts")
+ .isCloseTo(140, within(1.0));
+ }
+ }
+
+ @Test
+ void aCardWhoseTextATablesCellsHoldLeavesItWhereItWas() throws Exception {
+ // A table's own cells state no width of their own — a table's grid holds it — and such a
+ // cell is set to the middle, whose text LibreOffice 24.2 moves for a drawing it carries
+ // (measured for #977). So the cell is offered no drawing to carry, and the shape keeps to
+ // the text nearest it, as before.
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> {
+ page.addParagraph(p -> p.text("Above the card"));
+ page.addLayerStack(stack -> stack
+ .layer(new SectionBuilder().softPanel(SURFACE, 0, 10)
+ .addTable(table -> table.columns(DocumentTableColumn.fixed(200))
+ .rowCells(DocumentTableCell.text("A cell"))).build())
+ .layer(dot(), LayerAlign.TOP_RIGHT));
+ })) {
+ XWPFTableCell cellOfTheTable = document.getTables().get(0).getRow(0).getCell(0)
+ .getTables().get(0).getRow(0).getCell(0);
+
+ assertThat(placedFrom(cellOfTheTable.getParagraphs().get(0))).as("the cell takes none")
+ .isEmpty();
+ assertThat(placedFrom(document.getParagraphs().get(0)))
+ .as("the paragraph above the card keeps it, as before")
+ .containsExactly(STRelFromV.PARAGRAPH);
+ }
+ }
+
+ private static DocumentNode dot() {
+ return new EllipseNode("dot", 12, 12, INK, null, null, null, null, null);
+ }
+
+ /** The one Word paragraph two halves of a band share, found by the tab between them. */
+ private static XWPFParagraph shared(XWPFDocument document) {
+ return document.getParagraphs().stream()
+ .filter(para -> para.getText().contains("\t")).findFirst().orElseThrow();
+ }
+
+ private static XWPFParagraph firstItem(XWPFDocument document) {
+ return document.getTables().get(0).getRow(0).getCell(0).getParagraphs().stream()
+ .filter(para -> para.getCTP().getPPr() != null && para.getCTP().getPPr().isSetNumPr())
+ .findFirst().orElseThrow();
+ }
+
+ private static List placedFrom(XWPFParagraph paragraph) {
+ List from = new ArrayList<>();
+ for (CTAnchor anchor : anchorsIn(paragraph.getCTP())) {
+ from.add(anchor.getPositionV().getRelativeFrom());
+ }
+ return from;
+ }
+
+ private static double offsetDown(XWPFParagraph paragraph) {
+ return anchorsIn(paragraph.getCTP()).get(0).getPositionV().getPosOffset() / 12700.0;
+ }
+
+ private static double offsetAcross(XWPFParagraph paragraph) {
+ return anchorsIn(paragraph.getCTP()).get(0).getPositionH().getPosOffset() / 12700.0;
+ }
+
+ /** A cell's width as written, in points. */
+ private static double widthOf(XWPFTableCell cell) {
+ return ((Number) cell.getCTTc().getTcPr().getTcW().getW()).doubleValue() / 20;
+ }
+
+ /** The room a cell leaves its text at the left, as written, in points. */
+ private static double marginOf(XWPFTableCell cell) {
+ return ((Number) cell.getCTTc().getTcPr().getTcMar().getLeft().getW()).doubleValue() / 20;
+ }
+
+ private static long onThePage(XWPFDocument document) {
+ return anchorsIn(document.getDocument()).stream()
+ .filter(anchor -> anchor.getPositionV().getRelativeFrom() == STRelFromV.PAGE)
+ .count();
+ }
+
+ private static List anchorsIn(XmlObject root) {
+ List anchors = new ArrayList<>();
+ try (org.apache.xmlbeans.XmlCursor cursor = root.newCursor()) {
+ cursor.selectPath("declare namespace wp='http://schemas.openxmlformats.org/drawingml/2006/"
+ + "wordprocessingDrawing' .//wp:anchor");
+ while (cursor.toNextSelection()) {
+ if (cursor.getObject() instanceof CTAnchor anchor) {
+ anchors.add(anchor);
+ }
+ }
+ }
+ return anchors;
+ }
+}
From 259f0f08854ee625a09ab646821c7d1f9a55123f Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Thu, 29 Oct 2026 19:50:18 +0000
Subject: [PATCH 2/5] fix(docx): let a shared line reach to the far end of its
second half
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A drawing nothing holds is taken in by the text it stands nearest, counted from that text's
edges, and the line two halves of a band share was offered as the box of the half that starts
it — a title laid out 60pt wide on a line 360pt wide. A drawing beside the dates at the line's
right end was reckoned 250pt from it, and a paragraph of body text above took the drawing
instead: the very placement this is meant to end.
The place is offered for the whole line now (offerTheSharedLine): its left edge from the half
that starts the line, whose text begins where the line begins and whose raise Word reads back
from the line's first run, and its right edge from the far end of the other. A seat's right edge
is read nowhere else than in that distance, so what a block's own paragraph carries is unchanged.
No byte of the fidelity corpus moves: no document of it has a drawing whose nearest text the
line's right edge decided. A test holds the rule — the shared line takes a drawing beside the
dates although a paragraph of body text stands just above it — and a sabotage of it was caught.
---
CHANGELOG.md | 7 ++-
.../architecture/backend-capability-matrix.md | 9 +++-
.../semantic/docx/DocxSemanticBackend.java | 47 +++++++++++++++----
.../docx/DocxTextWriterAnchorTest.java | 42 +++++++++++++++--
4 files changed, 86 insertions(+), 19 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6cdde34d9..c0ee46d78 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,8 +25,11 @@ follow semantic versioning; release dates are ISO 8601.
item is anchored in that item — where the export matched the layout's items to the list's one
by one, which the items' line gap and their own seating need as well; a list with an item split
across a page is laid out as more pieces of text than it has items, and offers none. A band's
- shared line is offered as the half that starts it: both halves are written in that one
- paragraph, and that half's text begins where the line does.
+ shared line is offered as the half that starts it, reaching across to the far end of the other:
+ both halves are written in that one paragraph, and that half's text begins where the line does,
+ while how far the line reaches across is the whole line's — a drawing nothing holds is taken in
+ by the text it stands nearest, counted from that text's edges, and the half that starts a line
+ is laid out in a box of its own width.
- A table's own cell text offers no place yet, and a drawing whose block's text is written only
so keeps the nearest-text rule: a table's cells state no width of their own — their table's
grid holds it — and a drawing anchored in a cell is placed from the cell's text column, which
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index 19bbb1877..d98882f52 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -46,8 +46,13 @@ nodes are a different case: `ShapeNode`, `LineNode`, `EllipseNode`,
`DocxSemanticBackend.writeNode`. A horizontal `LineNode` or a thin filled
`ShapeNode` is written as a rule; any other rectangle, rounded rectangle,
ellipse, line, polygon or path is drawn from the fragments the layout placed
-it as, a floating DrawingML shape behind the text, anchored in the first paragraph the nearest block holding it writes on its page, whichever of three writers wrote that paragraph — a paragraph node's own, an item of a list the layout's items are matched to, the one line two halves of a band share; not a table's own cell text, which states no width for a shape to be placed from — the paragraph nearest it where none does, the first paragraph of the cell whose box holds it for what a composed cell draws — or else to the page (`DocxDrawings`, `DocxDrawingAnchors`,
-polygons and paths as custom geometry). Inside a filled panel a shape stands in
+it as, a floating DrawingML shape behind the text, anchored in the first paragraph the nearest block holding it writes on its page — the paragraph nearest it where none does, the first paragraph of the cell whose box holds it for what a composed cell draws — or else to the page (`DocxDrawings`, `DocxDrawingAnchors`,
+polygons and paths as custom geometry). Three writers offer a paragraph to be
+anchored in: a paragraph node's own, an item of a list the layout's items are
+matched to, and the one line two halves of a band share. A table's own cell text
+offers none — its cell states no width for a shape to be placed from — so a
+shape whose block's text is written only so keeps to the text nearest it.
+Inside a filled panel a shape stands in
front of the text unless it frames text; anchored in a paragraph of the panel's
cell, it is placed from the cell's text column, as Word lays a shape anchored in a
table cell out inside the cell. A filled or outlined
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 4bbdd186d..cf4d39071 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
@@ -6389,7 +6389,10 @@ private void seatTheItem(XWPFParagraph para, DocxLayoutMetrics.ItemText laidOut,
return;
}
// Each item is a piece of text of its own, offered to the shapes beside it: a shape the
- // list or a block round it holds takes the first item written on its page.
+ // list or a block round it holds takes the first item written on its page. Only a list
+ // whose items the layout's are matched to one by one gets this far: the list keeps the
+ // layout's items for the writers only then ({@link #listItemsMatched}), and an item with
+ // none of them is seated in no way at all (the guard above).
if (laidOut.box() != null && listPath != null) {
offerASeat(para, laidOut, listPath, laidOut.box(), 0);
}
@@ -9898,6 +9901,38 @@ private void offerASeatFor(XWPFParagraph para, ParagraphNode node) {
layout.firstTextBox(node).ifPresent(box -> offerASeat(para, node, layout.pathOf(node), box, seatShift(node)));
}
+ /**
+ * Offers the one line two halves of a band share to the shapes beside it, as the text of the
+ * half that starts it, reaching to the far end of the other.
+ *
+ * One place is enough for the line, since both halves are written in this one paragraph and
+ * a place stands for a paragraph. The half that starts it is the one whose text begins where
+ * the line begins, and whose raise Word reads back from the line's first run
+ * ({@link DocxDrawingAnchors.Seat#paragraphTop}); the line itself is raised by the lesser of
+ * what its two halves call for ({@link #raiseTheSharedLine}), so a shape placed from it stands
+ * as far below the page's place as the line does — 0.2 to 0.33pt, measured there.
+ *
+ * How far the text reaches across is the whole line's, not that half's: a shape is taken in
+ * by the text it stands nearest, counted from its edges ({@code DocxDrawingAnchors.distance}),
+ * and the half that starts the line is laid out in a box of its own width — a title at the
+ * left of a band is 120pt of a line 510pt wide. Offered as that box, the line stood 330pt from
+ * a shape beside the dates at its right end, and any other paragraph on the page took the
+ * shape instead.
+ */
+ private void offerTheSharedLine(XWPFParagraph para, DocxLinePair.Pair pair) {
+ ParagraphNode starts = pair.left();
+ java.util.Optional box = layout.firstTextBox(starts);
+ if (box.isEmpty()) {
+ return;
+ }
+ double ends = layout.firstTextBox(pair.right()).map(DocxLayoutMetrics.TextBox::right)
+ .orElse(box.get().right());
+ offerASeat(para, starts, layout.pathOf(starts),
+ new DocxLayoutMetrics.TextBox(box.get().page(), box.get().left(),
+ Math.max(box.get().right(), ends), box.get().top(), box.get().baseline()),
+ seatShift(starts));
+ }
+
/**
* Offers a paragraph written for any piece of text to the shapes standing beside it: a
* paragraph node's text, an item of a list whose items the layout's are matched to
@@ -11203,15 +11238,7 @@ private void writeLinePair(XWPFDocument document, DocumentNode overlay, DocxLine
int rightAnchor = openAnchor(para, pair.right().anchor());
writeParagraphRuns(para, pair.right(), false, lineTopAbove(lineTop, pair.right()), false);
closeAnchor(para, rightAnchor);
- // The line the two halves share is offered to the shapes beside it, as the text of the
- // half that starts it: a shape the overlay or a block round it holds takes that line. One
- // seat is enough for it, since both halves are written in this one paragraph and a seat
- // stands for a paragraph; the half that starts the line is the one whose text begins where
- // the line begins, and whose raise is the one Word reads back from the line's first run
- // (DocxDrawingAnchors.Seat#paragraphTop). The line itself is raised by the lesser of what
- // its two halves call for (raiseTheSharedLine), so a shape placed from it stands as far
- // below the page's place as the line does: 0.2 to 0.33pt, measured there.
- offerASeatFor(para, pair.left());
+ offerTheSharedLine(para, pair);
raiseTheSharedLine(para, pair, lineTop, opensItsPage);
// A Word paragraph has one outline level, and its outline lists the whole line by it: the
// left side's level where it has one, the right side's where not; the other's is lost.
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextWriterAnchorTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextWriterAnchorTest.java
index a45e1c5ca..1c0726df2 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextWriterAnchorTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextWriterAnchorTest.java
@@ -84,7 +84,10 @@ void theFirstItemCarriesOneStandingBesideTheLast() throws Exception {
XWPFParagraph item = firstItem(document);
assertThat(placedFrom(item)).containsExactly(STRelFromV.PARAGRAPH);
- assertThat(offsetDown(item)).as("below the first item's top, where it stands").isPositive();
+ // The card holds two items of one line and 10pt of padding either side: the dot at its
+ // foot stands below the first item's top by more than a line and less than the card.
+ assertThat(offsetDown(item)).as("below the first item's top, where it stands")
+ .isBetween(10.0, 60.0);
}
}
@@ -134,9 +137,13 @@ void theLineTwoHalvesShareCarriesTheDrawingsOfTheirBand() throws Exception {
@Test
void theSharedLineCarriesOneNoBlockHoldsThatStandsBesideIt() throws Exception {
// A dot no block carries — nothing holding it writes text — goes to the paragraph whose
- // text it stands nearest. The band's shared line is the only text on the page, and it is
- // offered: without it the dot would be left to the page's edges.
+ // text it stands nearest, counted from the text's edges. This one stands level with the
+ // band at its right end, beside the dates, with a paragraph of body text just above it:
+ // the line reaches across to the dates, so it is the nearer text. Offered as the box of
+ // the half that starts the line — a title 60pt of a line 360pt wide — the line would be
+ // reckoned 250pt away and the paragraph above would take the dot.
try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> {
+ page.addParagraph(p -> p.text("Above the band"));
page.addContainer(band -> band.name("EntryHead").rectangle(360, 16)
.clipPolicy(ClipPolicy.OVERFLOW_VISIBLE)
.position(new ParagraphBuilder().name("Title").text("Engineer")
@@ -144,12 +151,37 @@ void theSharedLineCarriesOneNoBlockHoldsThatStandsBesideIt() throws Exception {
.position(new ParagraphBuilder().name("Dates").text("2019 - 2024").align(TextAlign.RIGHT)
.textStyle(DocumentTextStyle.DEFAULT.withSize(9)).build(), 0, 0, LayerAlign.CENTER_RIGHT));
page.addEllipse(dot -> dot.name("Dot").circle(12).fillColor(INK)
- .margin(new DocumentInsets(0, 0, 0, 290)));
+ .margin(new DocumentInsets(-14, 0, 0, 290)));
})) {
XWPFParagraph shared = shared(document);
assertThat(placedFrom(shared)).as("the dot, beside the dates")
.containsExactly(STRelFromV.PARAGRAPH);
+ assertThat(placedFrom(document.getParagraphs().get(0))).as("not the paragraph above")
+ .isEmpty();
+ assertThat(onThePage(document)).isZero();
+ }
+ }
+
+ @Test
+ void aNestedItemCarriesThemToo() throws Exception {
+ // A nested item is written by the same writer and offered the same way; its own indent
+ // states the cell's text column, as a top-level item's does.
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> {
+ page.addParagraph(p -> p.text("Above the card"));
+ page.addLayerStack(stack -> stack
+ .layer(new SectionBuilder().softPanel(SURFACE, 0, 10)
+ .addList(list -> list.bullet().addItem("First item",
+ nested -> nested.items("Under it", "And under it"))).build())
+ .layer(dot(), LayerAlign.TOP_RIGHT));
+ })) {
+ XWPFTableCell card = document.getTables().get(0).getRow(0).getCell(0);
+ XWPFParagraph item = firstItem(document);
+
+ assertThat(placedFrom(item)).as("in the first item, nested items and all")
+ .containsExactly(STRelFromV.PARAGRAPH);
+ assertThat(offsetAcross(item)).as("from the cell's text column")
+ .isCloseTo(widthOf(card) - marginOf(card) - DOT, within(1.0));
assertThat(onThePage(document)).isZero();
}
}
@@ -205,7 +237,7 @@ void aCardWhoseTextATablesCellsHoldLeavesItWhereItWas() throws Exception {
}
private static DocumentNode dot() {
- return new EllipseNode("dot", 12, 12, INK, null, null, null, null, null);
+ return new EllipseNode("dot", DOT, DOT, INK, null, null, null, null, null);
}
/** The one Word paragraph two halves of a band share, found by the tab between them. */
From 066894e32c362e0eb4a65693917c1906ee72cce8 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Thu, 29 Oct 2026 20:16:34 +0000
Subject: [PATCH 3/5] fix(docx): state a shared line's own text start, not the
box its half is set in
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A drawing anchored in a table cell is placed from the cell's text column, and a place states that
column by its text's left edge less the indent written on its paragraph (Seat#textLeft).
writeLinePair writes that indent from the inked start of the half that starts the line, while the
place was offered with the edge of the box that half is laid out in — a box as wide as the band
where the half is set in it by its alignment. A band in a card whose starting half is centred in
its box had its column read short by the band's width, and the cell was given no drawing to carry
at all.
The place now states that inked start, which is where the line's own indent puts its text; its
right edge still reaches to the end of the other half's box, which is the line's own reach. The
two edges answer different questions, and the Javadoc says which.
No byte of the fidelity corpus moves: every band in it starts with a half laid out at its own
text's left, so the two edges agree there. A test holds the rule — a band whose starting half is
centred in a box as wide as the band, in a card, places its drawing from the cell's text column —
and a sabotage of it was caught.
Also: the recipe now describes both of the line's edges, as the CHANGELOG and the capability
matrix already did; the test that claimed to hold the nested item writer held the top-level one,
so it is a test of the writer that was really uncovered, a list whose marker the export writes
itself; and offerASeatFor, left with one caller, is folded into it.
---
CHANGELOG.md | 12 +++--
.../architecture/backend-capability-matrix.md | 4 +-
docs/recipes/docx-export.md | 7 +++
.../semantic/docx/DocxSemanticBackend.java | 47 +++++++++--------
.../docx/DocxTextWriterAnchorTest.java | 50 +++++++++++++++----
5 files changed, 83 insertions(+), 37 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c0ee46d78..0992072b3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,11 +25,13 @@ follow semantic versioning; release dates are ISO 8601.
item is anchored in that item — where the export matched the layout's items to the list's one
by one, which the items' line gap and their own seating need as well; a list with an item split
across a page is laid out as more pieces of text than it has items, and offers none. A band's
- shared line is offered as the half that starts it, reaching across to the far end of the other:
- both halves are written in that one paragraph, and that half's text begins where the line does,
- while how far the line reaches across is the whole line's — a drawing nothing holds is taken in
- by the text it stands nearest, counted from that text's edges, and the half that starts a line
- is laid out in a box of its own width.
+ shared line is offered as the half that starts it, from its text's own start and reaching
+ across to the end of the other's box: both halves are written in that one paragraph, and that
+ half's text begins where the line does. Its text's start is the inked one, not the edge of the
+ box the half is set in, because a drawing anchored in a cell is placed from the cell's text
+ column and that column is read from the place's left edge less the indent written on the
+ paragraph; and its reach is the whole line's, so that a drawing beside the dates at the line's
+ right end, or in the gap between the two texts, stands beside the line.
- A table's own cell text offers no place yet, and a drawing whose block's text is written only
so keeps the nearest-text rule: a table's cells state no width of their own — their table's
grid holds it — and a drawing anchored in a cell is placed from the cell's text column, which
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index d98882f52..642058a9e 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -51,7 +51,9 @@ polygons and paths as custom geometry). Three writers offer a paragraph to be
anchored in: a paragraph node's own, an item of a list the layout's items are
matched to, and the one line two halves of a band share. A table's own cell text
offers none — its cell states no width for a shape to be placed from — so a
-shape whose block's text is written only so keeps to the text nearest it.
+shape whose block's text is written only so keeps to the text nearest it. A
+shared line is offered from its text's own start, which its indent is written
+from, and reaches across to the end of the box its other half is set in.
Inside a filled panel a shape stands in
front of the text unless it frames text; anchored in a paragraph of the panel's
cell, it is placed from the cell's text column, as Word lays a shape anchored in a
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index b58b213f9..b38bb12f3 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -1523,6 +1523,13 @@ paragraph, in a column's cell too; each item of a list, so that a card whose onl
list's items carries the drawings laid over it, and a shape standing beside the third item takes
that item; and the one line two halves of a band share — a title at the left, its dates at the
right — offered as the half that starts the line, since both are written in that one paragraph.
+That line's text starts where the line's own indent puts it, at that half's inked start rather
+than at the edge of the box it is laid out in, which is as wide as the band where the half is set
+in it by its alignment: a shape anchored in a cell is placed from the cell's text column, and the
+column is read from the place's left edge less the indent written on the paragraph, so the two
+must be the one edge. How far the line reaches across is the whole line's, to the end of the box
+the other half is laid out in, so that a shape beside the dates at its right end, or in the gap
+between the two texts, stands beside the line and not beside some other paragraph.
An item offers its place only where the export matched the layout's items to the list's one by
one, which its line gap and its own seating need as well: a list with an item split across a
page is laid out as more pieces of text than it has items, and none of its items offers a place.
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 cf4d39071..744d10d70 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
@@ -6391,7 +6391,7 @@ private void seatTheItem(XWPFParagraph para, DocxLayoutMetrics.ItemText laidOut,
// Each item is a piece of text of its own, offered to the shapes beside it: a shape the
// list or a block round it holds takes the first item written on its page. Only a list
// whose items the layout's are matched to one by one gets this far: the list keeps the
- // layout's items for the writers only then ({@link #listItemsMatched}), and an item with
+ // layout's items for the writers only then (listItemsMatched), and an item with
// none of them is seated in no way at all (the guard above).
if (laidOut.box() != null && listPath != null) {
offerASeat(para, laidOut, listPath, laidOut.box(), 0);
@@ -9888,22 +9888,16 @@ private XWPFParagraph newBodyParagraph(XWPFDocument document) {
* first of them that holds text takes the place.
*/
private void offerASeat(XWPFParagraph para) {
- if (writing instanceof ParagraphNode node) {
- offerASeatFor(para, node);
+ if (!(writing instanceof ParagraphNode node)) {
+ return;
}
- }
-
- /**
- * Offers a paragraph to the shapes beside the text of one paragraph node written in it, with
- * the baseline the page draws its first line on, seated as the page seats it.
- */
- private void offerASeatFor(XWPFParagraph para, ParagraphNode node) {
+ // The baseline the page draws the first line on, seated as the page seats it.
layout.firstTextBox(node).ifPresent(box -> offerASeat(para, node, layout.pathOf(node), box, seatShift(node)));
}
/**
* Offers the one line two halves of a band share to the shapes beside it, as the text of the
- * half that starts it, reaching to the far end of the other.
+ * half that starts it, reaching across to the end of the other's box.
*
* One place is enough for the line, since both halves are written in this one paragraph and
* a place stands for a paragraph. The half that starts it is the one whose text begins where
@@ -9912,23 +9906,32 @@ private void offerASeatFor(XWPFParagraph para, ParagraphNode node) {
* what its two halves call for ({@link #raiseTheSharedLine}), so a shape placed from it stands
* as far below the page's place as the line does — 0.2 to 0.33pt, measured there.
*
- * How far the text reaches across is the whole line's, not that half's: a shape is taken in
- * by the text it stands nearest, counted from its edges ({@code DocxDrawingAnchors.distance}),
- * and the half that starts the line is laid out in a box of its own width — a title at the
- * left of a band is 120pt of a line 510pt wide. Offered as that box, the line stood 330pt from
- * a shape beside the dates at its right end, and any other paragraph on the page took the
- * shape instead.
- */
- private void offerTheSharedLine(XWPFParagraph para, DocxLinePair.Pair pair) {
+ * Where its text starts is where the line's own indent puts it: the half's inked start, not
+ * the box it is laid out in, which is as wide as the band where the half is set in it by its
+ * alignment. A shape anchored in a table cell is placed from the cell's text column, and a
+ * place states that column by its text's left edge less the indent written on it
+ * ({@link DocxDrawingAnchors.Seat#textLeft}) — the two must be the one edge, or the column
+ * comes out short by the band's width and the cell is given no shape to carry.
+ *
+ * How far it reaches across is the whole line's, not that half's: a shape standing beside
+ * the dates at the line's right end, or in the gap between the two texts, stands beside the
+ * line, and the text nearest a shape is reckoned from its edges
+ * ({@code DocxDrawingAnchors.distance}). The half that starts the line is laid out in a box of
+ * its own width — the title of the test's band is 60pt of a line 360pt wide — and offered as
+ * that box the line was reckoned 250pt from a shape beside the dates, which a paragraph of
+ * body text above it then took.
+ */
+ private void offerTheSharedLine(XWPFParagraph para, DocumentNode overlay, DocxLinePair.Pair pair) {
ParagraphNode starts = pair.left();
+ com.demcha.compose.document.layout.PlacedNode band = layout.placement(overlay);
java.util.Optional box = layout.firstTextBox(starts);
- if (box.isEmpty()) {
+ if (band == null || box.isEmpty()) {
return;
}
double ends = layout.firstTextBox(pair.right()).map(DocxLayoutMetrics.TextBox::right)
.orElse(box.get().right());
offerASeat(para, starts, layout.pathOf(starts),
- new DocxLayoutMetrics.TextBox(box.get().page(), box.get().left(),
+ new DocxLayoutMetrics.TextBox(box.get().page(), band.placementX() + pair.leftOffset(),
Math.max(box.get().right(), ends), box.get().top(), box.get().baseline()),
seatShift(starts));
}
@@ -11238,7 +11241,7 @@ private void writeLinePair(XWPFDocument document, DocumentNode overlay, DocxLine
int rightAnchor = openAnchor(para, pair.right().anchor());
writeParagraphRuns(para, pair.right(), false, lineTopAbove(lineTop, pair.right()), false);
closeAnchor(para, rightAnchor);
- offerTheSharedLine(para, pair);
+ offerTheSharedLine(para, overlay, pair);
raiseTheSharedLine(para, pair, lineTop, opensItsPage);
// A Word paragraph has one outline level, and its outline lists the whole line by it: the
// left side's level where it has one, the right side's where not; the other's is lost.
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextWriterAnchorTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextWriterAnchorTest.java
index 1c0726df2..370a2f9b8 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextWriterAnchorTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextWriterAnchorTest.java
@@ -85,9 +85,10 @@ void theFirstItemCarriesOneStandingBesideTheLast() throws Exception {
assertThat(placedFrom(item)).containsExactly(STRelFromV.PARAGRAPH);
// The card holds two items of one line and 10pt of padding either side: the dot at its
- // foot stands below the first item's top by more than a line and less than the card.
+ // foot stands below the first item's top by about the second item's line and the
+ // padding under it, and no further than the card is tall.
assertThat(offsetDown(item)).as("below the first item's top, where it stands")
- .isBetween(10.0, 60.0);
+ .isBetween(DOT, 60.0);
}
}
@@ -164,21 +165,22 @@ void theSharedLineCarriesOneNoBlockHoldsThatStandsBesideIt() throws Exception {
}
@Test
- void aNestedItemCarriesThemToo() throws Exception {
- // A nested item is written by the same writer and offered the same way; its own indent
- // states the cell's text column, as a top-level item's does.
+ void anItemWrittenWithItsOwnMarkerCarriesThemToo() throws Exception {
+ // A list whose marker the export writes itself, rather than leaving it to Word, is
+ // written by the other item writer (writeRichListLine), which offers its place by the
+ // same call and indents its text to its own column.
try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> {
page.addParagraph(p -> p.text("Above the card"));
page.addLayerStack(stack -> stack
.layer(new SectionBuilder().softPanel(SURFACE, 0, 10)
- .addList(list -> list.bullet().addItem("First item",
- nested -> nested.items("Under it", "And under it"))).build())
+ .addList(list -> list.marker("*").items("First item", "Second item")).build())
.layer(dot(), LayerAlign.TOP_RIGHT));
})) {
XWPFTableCell card = document.getTables().get(0).getRow(0).getCell(0);
- XWPFParagraph item = firstItem(document);
+ XWPFParagraph item = card.getParagraphs().stream()
+ .filter(para -> para.getText().contains("First item")).findFirst().orElseThrow();
- assertThat(placedFrom(item)).as("in the first item, nested items and all")
+ assertThat(placedFrom(item)).as("in the first item, marker and all")
.containsExactly(STRelFromV.PARAGRAPH);
assertThat(offsetAcross(item)).as("from the cell's text column")
.isCloseTo(widthOf(card) - marginOf(card) - DOT, within(1.0));
@@ -186,6 +188,36 @@ void aNestedItemCarriesThemToo() throws Exception {
}
}
+ @Test
+ void aSharedLineStatesItsTextsOwnStartNotTheBoxItIsSetIn() throws Exception {
+ // The line's indent is written from the inked start of the half that starts it, and a
+ // shape anchored in a cell is placed from the cell's text column, which the place states
+ // by its left edge less that indent. Here the band's title is centred in a box as wide as
+ // the band, so its box starts a long way left of its text: stated as the box, the column
+ // would come out short by that much and the cell would be given no shape to carry.
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.addSection(
+ card -> card.softPanel(SURFACE, 0, 10).addContainer(band -> band.name("EntryHead")
+ .rectangle(320, 16).clipPolicy(ClipPolicy.OVERFLOW_VISIBLE)
+ .position(new ParagraphBuilder().name("Title").text("Engineer")
+ .align(TextAlign.CENTER)
+ .textStyle(DocumentTextStyle.DEFAULT.withSize(9)).build(), 0, 0,
+ LayerAlign.CENTER_LEFT)
+ .position(new ParagraphBuilder().name("Dates").text("2019 - 2024")
+ .align(TextAlign.RIGHT)
+ .textStyle(DocumentTextStyle.DEFAULT.withSize(9)).build(), 0, 0,
+ LayerAlign.CENTER_RIGHT)
+ .position(dot(), 150, 0, LayerAlign.CENTER_LEFT))))) {
+ XWPFTableCell card = document.getTables().get(0).getRow(0).getCell(0);
+ XWPFParagraph shared = card.getParagraphs().stream()
+ .filter(para -> para.getText().contains("\t")).findFirst().orElseThrow();
+
+ assertThat(placedFrom(shared)).as("the cell carries it")
+ .containsExactly(STRelFromV.PARAGRAPH);
+ assertThat(offsetAcross(shared)).as("from the cell's text column, where the page draws it")
+ .isCloseTo(150, within(1.0));
+ }
+ }
+
@Test
void aSharedLineInACellPlacesItFromTheCellsTextColumn() throws Exception {
// A shape anchored in a cell's paragraph is placed from the cell's text column, which the
From fb13f6467af9f5b4ca7efc1cf3653263f10a9d4a Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Thu, 29 Oct 2026 20:50:38 +0000
Subject: [PATCH 4/5] fix(docx): offer no place for a line hung past its cell's
padding
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Word draws no text past a cell's left edge, so a line pulled out past its card's padding is
written with no indent at all (leftIndentTwips) and its text does not start where the layout puts
it. A place states the cell's text column by its text's left edge less the indent written on the
paragraph, so such a line cannot state it: the column came out as far left as the line is hung,
and the drawing was placed from there or handed to a neighbouring cell. Such a line now offers no
place, and the drawings beside it keep to the text nearest them, as they did before a shared line
offered one. A test holds it and a sabotage of it was caught.
The sentence saying a cell was given no drawing to carry at all was wrong, in the Javadoc, the
CHANGELOG and the recipe alike: the cell does carry it, from the wrong column — 292pt across its
cell where the page draws it at 150, measured on the test's card. All three now say that.
The test that claimed to hold the item writer with a marker the export writes itself held neither:
marker("*") is a marker Word draws, so it went through the writer three other tests already
cover. It is a drawn marker now, which has no Word list definition to be given, and the test says
the document was laid out — a drawn marker in a stack's panel is not laid out through items(...),
and without a layout nothing is drawn and the rest of the test proves nothing.
No byte of the fidelity corpus moves.
---
CHANGELOG.md | 10 ++-
docs/recipes/docx-export.md | 10 ++-
.../semantic/docx/DocxSemanticBackend.java | 23 ++++--
.../docx/DocxTextWriterAnchorTest.java | 76 +++++++++++++++----
4 files changed, 94 insertions(+), 25 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0992072b3..2caccbf30 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -30,12 +30,16 @@ follow semantic versioning; release dates are ISO 8601.
half's text begins where the line does. Its text's start is the inked one, not the edge of the
box the half is set in, because a drawing anchored in a cell is placed from the cell's text
column and that column is read from the place's left edge less the indent written on the
- paragraph; and its reach is the whole line's, so that a drawing beside the dates at the line's
- right end, or in the gap between the two texts, stands beside the line.
+ paragraph: stated as the box, the column came out the band's width to the left, and a card
+ carried its drawing 292pt across its cell where the page draws it at 150. The line's reach
+ across is the whole line's, so that a drawing beside the dates at its right end, or in the gap
+ between the two texts, stands beside the line. A line hung left past its cell's padding offers
+ no place at all: Word draws no text past a cell's left edge, so such a line is written with no
+ indent and its text does not start where the layout puts it.
- A table's own cell text offers no place yet, and a drawing whose block's text is written only
so keeps the nearest-text rule: a table's cells state no width of their own — their table's
grid holds it — and a drawing anchored in a cell is placed from the cell's text column, which
- cannot be read without it; a text cell is set to the middle by default besides, and such a
+ cannot be read without that width; a text cell is set to the middle by default besides, and such a
cell carries no drawing.
- **A DOCX export anchors what a table's composed cell draws in that cell, so it moves with the
cell's row when the text above is edited.**
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index b38bb12f3..526db0045 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -1527,9 +1527,13 @@ That line's text starts where the line's own indent puts it, at that half's inke
than at the edge of the box it is laid out in, which is as wide as the band where the half is set
in it by its alignment: a shape anchored in a cell is placed from the cell's text column, and the
column is read from the place's left edge less the indent written on the paragraph, so the two
-must be the one edge. How far the line reaches across is the whole line's, to the end of the box
-the other half is laid out in, so that a shape beside the dates at its right end, or in the gap
-between the two texts, stands beside the line and not beside some other paragraph.
+must be the one edge — stated as the box, the column comes out the band's width to the left and
+the cell carries the shape from there. How far the line reaches across is the whole line's, to the
+end of the box the other half is laid out in, so that a shape beside the dates at its right end,
+or in the gap between the two texts, stands beside the line and not beside some other paragraph.
+A line hung left past its cell's padding offers no place: Word draws no text past a cell's left
+edge, so such a line is written with no indent at all and its text does not start where the
+layout puts it, and the shapes beside it keep to the text nearest them.
An item offers its place only where the export matched the layout's items to the list's one by
one, which its line gap and its own seating need as well: a list with an item split across a
page is laid out as more pieces of text than it has items, and none of its items offers a place.
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 744d10d70..4e743cc76 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
@@ -9911,7 +9911,14 @@ private void offerASeat(XWPFParagraph para) {
* alignment. A shape anchored in a table cell is placed from the cell's text column, and a
* place states that column by its text's left edge less the indent written on it
* ({@link DocxDrawingAnchors.Seat#textLeft}) — the two must be the one edge, or the column
- * comes out short by the band's width and the cell is given no shape to carry.
+ * comes out the band's width to the left and the cell carries the shape from there: measured
+ * on the test's card, 292pt across its cell instead of 150.
+ *
+ * A line hung left past its cell's padding offers no place at all. Word draws no text past
+ * a cell's left edge, so such a line is written with no indent ({@link #leftIndentTwips}) and
+ * its text does not start where the layout puts it; the cell's text column cannot be read back
+ * from the place, and the shapes beside the line keep to the text nearest them, as they did
+ * before any of this.
*
* How far it reaches across is the whole line's, not that half's: a shape standing beside
* the dates at the line's right end, or in the gap between the two texts, stands beside the
@@ -9920,12 +9927,18 @@ private void offerASeat(XWPFParagraph para) {
* its own width — the title of the test's band is 60pt of a line 360pt wide — and offered as
* that box the line was reckoned 250pt from a shape beside the dates, which a paragraph of
* body text above it then took.
+ *
+ * @param band where the layout placed the overlay, the box both halves are set in
+ * @param lineInset where the line's text is written from, inside its cell
*/
- private void offerTheSharedLine(XWPFParagraph para, DocumentNode overlay, DocxLinePair.Pair pair) {
+ private void offerTheSharedLine(XWPFParagraph para, com.demcha.compose.document.layout.PlacedNode band,
+ DocxLinePair.Pair pair, double lineInset) {
+ if (currentCell != null && lineInset + cellTextShift < 0) {
+ return;
+ }
ParagraphNode starts = pair.left();
- com.demcha.compose.document.layout.PlacedNode band = layout.placement(overlay);
java.util.Optional box = layout.firstTextBox(starts);
- if (band == null || box.isEmpty()) {
+ if (box.isEmpty()) {
return;
}
double ends = layout.firstTextBox(pair.right()).map(DocxLayoutMetrics.TextBox::right)
@@ -11241,7 +11254,7 @@ private void writeLinePair(XWPFDocument document, DocumentNode overlay, DocxLine
int rightAnchor = openAnchor(para, pair.right().anchor());
writeParagraphRuns(para, pair.right(), false, lineTopAbove(lineTop, pair.right()), false);
closeAnchor(para, rightAnchor);
- offerTheSharedLine(para, overlay, pair);
+ offerTheSharedLine(para, box, pair, lineStart + pair.leftOffset());
raiseTheSharedLine(para, pair, lineTop, opensItsPage);
// A Word paragraph has one outline level, and its outline lists the whole line by it: the
// left side's level where it has one, the right side's where not; the other's is lost.
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextWriterAnchorTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextWriterAnchorTest.java
index 370a2f9b8..354df8016 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextWriterAnchorTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextWriterAnchorTest.java
@@ -44,6 +44,9 @@ class DocxTextWriterAnchorTest {
private static final DocumentColor INK = DocumentColor.rgb(20, 30, 40);
private static final DocumentColor SURFACE = DocumentColor.rgb(230, 240, 250);
private static final double DOT = 12;
+ private static final double CARD_PADDING = 10;
+ /** Where a band's dot is laid across it, from the band's left edge, in points. */
+ private static final double DOT_ACROSS = 150;
@Test
void aCardWhoseTextIsAListsItemsCarriesTheDrawingLaidOverIt() throws Exception {
@@ -88,7 +91,7 @@ void theFirstItemCarriesOneStandingBesideTheLast() throws Exception {
// foot stands below the first item's top by about the second item's line and the
// padding under it, and no further than the card is tall.
assertThat(offsetDown(item)).as("below the first item's top, where it stands")
- .isBetween(DOT, 60.0);
+ .isBetween(9.0, 60.0);
}
}
@@ -165,25 +168,31 @@ void theSharedLineCarriesOneNoBlockHoldsThatStandsBesideIt() throws Exception {
}
@Test
- void anItemWrittenWithItsOwnMarkerCarriesThemToo() throws Exception {
- // A list whose marker the export writes itself, rather than leaving it to Word, is
- // written by the other item writer (writeRichListLine), which offers its place by the
- // same call and indents its text to its own column.
- try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> {
+ void anItemWhoseMarkerIsDrawnCarriesThemToo() throws Exception {
+ // A list whose marker is drawn has no Word list definition to be given, so its items go
+ // through the other item writer (writeRichListLine), which offers their place by the same
+ // call and tabs their text past the marker.
+ java.util.function.Consumer card = page -> {
page.addParagraph(p -> p.text("Above the card"));
page.addLayerStack(stack -> stack
- .layer(new SectionBuilder().softPanel(SURFACE, 0, 10)
- .addList(list -> list.marker("*").items("First item", "Second item")).build())
+ .layer(new SectionBuilder().softPanel(SURFACE, 0, CARD_PADDING)
+ .addList(list -> list.name("Skills").marker(m -> m.dot(4, INK))
+ .markerGap(6).hangingIndent(true).normalizeMarkers(false)
+ .addItem("First item").addItem("Second item")).build())
.layer(dot(), LayerAlign.TOP_RIGHT));
- })) {
- XWPFTableCell card = document.getTables().get(0).getRow(0).getCell(0);
- XWPFParagraph item = card.getParagraphs().stream()
+ };
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, card)) {
+ XWPFTableCell panel = document.getTables().get(0).getRow(0).getCell(0);
+ XWPFParagraph item = panel.getParagraphs().stream()
.filter(para -> para.getText().contains("First item")).findFirst().orElseThrow();
+ assertThat(DocxExports.report(400, 600, 20, card).bySubject())
+ .as("laid out — without a layout nothing is drawn and the rest proves nothing")
+ .doesNotContainKey("measured geometry");
assertThat(placedFrom(item)).as("in the first item, marker and all")
.containsExactly(STRelFromV.PARAGRAPH);
assertThat(offsetAcross(item)).as("from the cell's text column")
- .isCloseTo(widthOf(card) - marginOf(card) - DOT, within(1.0));
+ .isCloseTo(widthOf(panel) - marginOf(panel) - DOT, within(1.0));
assertThat(onThePage(document)).isZero();
}
}
@@ -206,7 +215,7 @@ void aSharedLineStatesItsTextsOwnStartNotTheBoxItIsSetIn() throws Exception {
.align(TextAlign.RIGHT)
.textStyle(DocumentTextStyle.DEFAULT.withSize(9)).build(), 0, 0,
LayerAlign.CENTER_RIGHT)
- .position(dot(), 150, 0, LayerAlign.CENTER_LEFT))))) {
+ .position(dot(), DOT_ACROSS, 0, LayerAlign.CENTER_LEFT))))) {
XWPFTableCell card = document.getTables().get(0).getRow(0).getCell(0);
XWPFParagraph shared = card.getParagraphs().stream()
.filter(para -> para.getText().contains("\t")).findFirst().orElseThrow();
@@ -214,7 +223,7 @@ void aSharedLineStatesItsTextsOwnStartNotTheBoxItIsSetIn() throws Exception {
assertThat(placedFrom(shared)).as("the cell carries it")
.containsExactly(STRelFromV.PARAGRAPH);
assertThat(offsetAcross(shared)).as("from the cell's text column, where the page draws it")
- .isCloseTo(150, within(1.0));
+ .isCloseTo(DOT_ACROSS, within(1.0));
}
}
@@ -243,6 +252,36 @@ void aSharedLineInACellPlacesItFromTheCellsTextColumn() throws Exception {
}
}
+ @Test
+ void aSharedLineHungPastItsCellsPaddingOffersNone() throws Exception {
+ // Word draws no text past a cell's left edge, so a line pulled out past its card's
+ // padding is written with no indent at all and its text does not start where the layout
+ // puts it. The cell's text column cannot be read back from such a place, so the line
+ // offers none and the drawing keeps to the text nearest it, as before this rule.
+ try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> {
+ page.addParagraph(p -> p.text("Above the card"));
+ page.addSection(card -> card.softPanel(SURFACE, 0, CARD_PADDING)
+ .addContainer(band -> band.name("EntryHead").rectangle(320, 16)
+ .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE)
+ .margin(new DocumentInsets(0, 0, 0, -(CARD_PADDING + 2)))
+ .position(new ParagraphBuilder().name("Title").text("Engineer")
+ .textStyle(DocumentTextStyle.DEFAULT.withSize(9)).build(), 0, 0,
+ LayerAlign.CENTER_LEFT)
+ .position(new ParagraphBuilder().name("Dates").text("2019 - 2024")
+ .align(TextAlign.RIGHT)
+ .textStyle(DocumentTextStyle.DEFAULT.withSize(9)).build(), 0, 0,
+ LayerAlign.CENTER_RIGHT)
+ .position(dot(), DOT_ACROSS, 0, LayerAlign.CENTER_LEFT)));
+ })) {
+ XWPFTableCell card = document.getTables().get(0).getRow(0).getCell(0);
+ XWPFParagraph shared = card.getParagraphs().stream()
+ .filter(para -> para.getText().contains("\t")).findFirst().orElseThrow();
+
+ assertThat(indentOf(shared)).as("written with no indent, hung out as it is").isZero();
+ assertThat(placedFrom(shared)).as("the line takes none").isEmpty();
+ }
+ }
+
@Test
void aCardWhoseTextATablesCellsHoldLeavesItWhereItWas() throws Exception {
// A table's own cells state no width of their own — a table's grid holds it — and such a
@@ -300,6 +339,15 @@ private static double offsetAcross(XWPFParagraph paragraph) {
return anchorsIn(paragraph.getCTP()).get(0).getPositionH().getPosOffset() / 12700.0;
}
+ /** The left indent written on a paragraph, in points; 0 where none is written. */
+ private static double indentOf(XWPFParagraph paragraph) {
+ var properties = paragraph.getCTP().getPPr();
+ if (properties == null || !properties.isSetInd() || !properties.getInd().isSetLeft()) {
+ return 0;
+ }
+ return ((Number) properties.getInd().getLeft()).doubleValue() / 20;
+ }
+
/** A cell's width as written, in points. */
private static double widthOf(XWPFTableCell cell) {
return ((Number) cell.getCTTc().getTcPr().getTcW().getW()).doubleValue() / 20;
From ac3335d8846ac4080cfafb4ccede687e23e4637a Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Thu, 29 Oct 2026 21:12:38 +0000
Subject: [PATCH 5/5] docs(docx): say what the hung-line rule holds, and hold
it from both sides
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The rule is that a line the layout starts left of its cell's text column offers no place for the
drawings beside it — a band pulled out by a margin below zero, into the cell's padding or past it.
The Javadoc, the CHANGELOG, the recipe and the test's name all said "past its cell's padding",
which is narrower than the code: Word holds the indent at nought for a line hung into the padding
as well. The capability matrix, which said a shared line is offered from the start its indent is
written from, now names the case where that is not so.
The test comment that still carried the sentence the commit before last refuted — that such a cell
is given no shape to carry — says what was measured instead: the cell carries it, from the wrong
column. The hung-line test now also says where the drawing goes, as its neighbour does, so losing
it to the page would redden it; and the shared line's place is held from above as well as across,
by the offset down from the line's own top.
Tests 10, and each of the eight rules broken once and caught.
---
CHANGELOG.md | 7 ++++---
.../architecture/backend-capability-matrix.md | 4 +++-
docs/recipes/docx-export.md | 5 +++--
.../semantic/docx/DocxSemanticBackend.java | 13 +++++++-----
.../docx/DocxTextWriterAnchorTest.java | 20 +++++++++++++------
5 files changed, 32 insertions(+), 17 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2caccbf30..bfe8302e8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -33,9 +33,10 @@ follow semantic versioning; release dates are ISO 8601.
paragraph: stated as the box, the column came out the band's width to the left, and a card
carried its drawing 292pt across its cell where the page draws it at 150. The line's reach
across is the whole line's, so that a drawing beside the dates at its right end, or in the gap
- between the two texts, stands beside the line. A line hung left past its cell's padding offers
- no place at all: Word draws no text past a cell's left edge, so such a line is written with no
- indent and its text does not start where the layout puts it.
+ between the two texts, stands beside the line. A line the layout starts left of its cell's text
+ column offers no place at all — a band pulled out by a margin below zero, into the cell's
+ padding or past it: Word draws no text past a cell's left edge, so the indent written for such
+ a line is held at nought and its text does not start where the layout puts it.
- A table's own cell text offers no place yet, and a drawing whose block's text is written only
so keeps the nearest-text rule: a table's cells state no width of their own — their table's
grid holds it — and a drawing anchored in a cell is placed from the cell's text column, which
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index 642058a9e..a16f66eb5 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -53,7 +53,9 @@ matched to, and the one line two halves of a band share. A table's own cell text
offers none — its cell states no width for a shape to be placed from — so a
shape whose block's text is written only so keeps to the text nearest it. A
shared line is offered from its text's own start, which its indent is written
-from, and reaches across to the end of the box its other half is set in.
+from, and reaches across to the end of the box its other half is set in; one
+the layout starts left of its cell's text column offers none, its indent being
+held at nought there.
Inside a filled panel a shape stands in
front of the text unless it frames text; anchored in a paragraph of the panel's
cell, it is placed from the cell's text column, as Word lays a shape anchored in a
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 526db0045..f9ace63fb 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -1531,8 +1531,9 @@ must be the one edge — stated as the box, the column comes out the band's widt
the cell carries the shape from there. How far the line reaches across is the whole line's, to the
end of the box the other half is laid out in, so that a shape beside the dates at its right end,
or in the gap between the two texts, stands beside the line and not beside some other paragraph.
-A line hung left past its cell's padding offers no place: Word draws no text past a cell's left
-edge, so such a line is written with no indent at all and its text does not start where the
+A line the layout starts left of its cell's text column offers no place — a band pulled out by a
+margin below zero, into the cell's padding or past it: Word draws no text past a cell's left edge,
+so the indent written for such a line is held at nought and its text does not start where the
layout puts it, and the shapes beside it keep to the text nearest them.
An item offers its place only where the export matched the layout's items to the list's one by
one, which its line gap and its own seating need as well: a list with an item split across a
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 4e743cc76..da4fedce4 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
@@ -9914,11 +9914,12 @@ private void offerASeat(XWPFParagraph para) {
* comes out the band's width to the left and the cell carries the shape from there: measured
* on the test's card, 292pt across its cell instead of 150.
*
- * A line hung left past its cell's padding offers no place at all. Word draws no text past
- * a cell's left edge, so such a line is written with no indent ({@link #leftIndentTwips}) and
- * its text does not start where the layout puts it; the cell's text column cannot be read back
- * from the place, and the shapes beside the line keep to the text nearest them, as they did
- * before any of this.
+ * A line the layout starts left of its cell's text column offers no place at all — a band
+ * pulled out by a margin below zero, whether into the cell's padding or past it. Word draws no
+ * text past a cell's left edge, so the indent written for such a line is held at nought
+ * ({@link #leftIndentTwips}) and its text does not start where the layout puts it; the cell's
+ * text column cannot be read back from the place, and the shapes beside the line keep to the
+ * text nearest them, as they did before any of this.
*
* How far it reaches across is the whole line's, not that half's: a shape standing beside
* the dates at the line's right end, or in the gap between the two texts, stands beside the
@@ -9928,7 +9929,9 @@ private void offerASeat(XWPFParagraph para) {
* that box the line was reckoned 250pt from a shape beside the dates, which a paragraph of
* body text above it then took.
*
+ * @param para the one paragraph the two halves are written in
* @param band where the layout placed the overlay, the box both halves are set in
+ * @param pair the two halves, as {@link DocxLinePair} read them
* @param lineInset where the line's text is written from, inside its cell
*/
private void offerTheSharedLine(XWPFParagraph para, com.demcha.compose.document.layout.PlacedNode band,
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextWriterAnchorTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextWriterAnchorTest.java
index 354df8016..116956ec5 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextWriterAnchorTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTextWriterAnchorTest.java
@@ -203,7 +203,7 @@ void aSharedLineStatesItsTextsOwnStartNotTheBoxItIsSetIn() throws Exception {
// shape anchored in a cell is placed from the cell's text column, which the place states
// by its left edge less that indent. Here the band's title is centred in a box as wide as
// the band, so its box starts a long way left of its text: stated as the box, the column
- // would come out short by that much and the cell would be given no shape to carry.
+ // comes out that much to the left and the cell carries the shape from there.
try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> page.addSection(
card -> card.softPanel(SURFACE, 0, 10).addContainer(band -> band.name("EntryHead")
.rectangle(320, 16).clipPolicy(ClipPolicy.OVERFLOW_VISIBLE)
@@ -224,6 +224,10 @@ void aSharedLineStatesItsTextsOwnStartNotTheBoxItIsSetIn() throws Exception {
.containsExactly(STRelFromV.PARAGRAPH);
assertThat(offsetAcross(shared)).as("from the cell's text column, where the page draws it")
.isCloseTo(DOT_ACROSS, within(1.0));
+ // The line's own top, from which it is placed down: the dot is centred on the band, a
+ // little below where the line starts, and well inside the band's height.
+ assertThat(offsetDown(shared)).as("down from the line's top, where it stands")
+ .isBetween(0.0, 16.0);
}
}
@@ -253,10 +257,10 @@ void aSharedLineInACellPlacesItFromTheCellsTextColumn() throws Exception {
}
@Test
- void aSharedLineHungPastItsCellsPaddingOffersNone() throws Exception {
- // Word draws no text past a cell's left edge, so a line pulled out past its card's
- // padding is written with no indent at all and its text does not start where the layout
- // puts it. The cell's text column cannot be read back from such a place, so the line
+ void aSharedLineStartedLeftOfItsCellsTextColumnOffersNone() throws Exception {
+ // Word draws no text past a cell's left edge, so the indent written for a line the layout
+ // starts left of its cell's text column is held at nought and its text does not start
+ // where the layout puts it. The column cannot be read back from such a place, so the line
// offers none and the drawing keeps to the text nearest it, as before this rule.
try (XWPFDocument document = DocxExports.withLayout(400, 600, 20, page -> {
page.addParagraph(p -> p.text("Above the card"));
@@ -277,8 +281,12 @@ void aSharedLineHungPastItsCellsPaddingOffersNone() throws Exception {
XWPFParagraph shared = card.getParagraphs().stream()
.filter(para -> para.getText().contains("\t")).findFirst().orElseThrow();
- assertThat(indentOf(shared)).as("written with no indent, hung out as it is").isZero();
+ assertThat(indentOf(shared)).as("its indent held at nought, hung out as it is").isZero();
assertThat(placedFrom(shared)).as("the line takes none").isEmpty();
+ assertThat(placedFrom(document.getParagraphs().get(0)))
+ .as("the paragraph above the card keeps it, as before this rule")
+ .containsExactly(STRelFromV.PARAGRAPH);
+ assertThat(onThePage(document)).as("and it is not left to the page").isZero();
}
}