From dd9996325477de56260db1e9d3a98548b9743ade Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Wed, 30 Sep 2026 10:36:31 +0100 Subject: [PATCH 1/2] fix(docx): hold a value set from its start by a left tab stop --- CHANGELOG.md | 9 ++++ .../backend/semantic/docx/DocxLinePair.java | 43 +++++++++++++++++-- .../semantic/docx/DocxSemanticBackend.java | 4 +- .../semantic/docx/DocxLinePairTest.java | 23 ++++++++++ 4 files changed, 74 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8a544bbc..c645c3a5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A value set from its start keeps its start in Word.** Two texts one layer holds on one line — + a label and its value, a title and its dates — are written as one Word line split by a tab + stop, and the stop was always a right tab at the right text's end. A value aligned left in a + layer placed from the left starts at its column whatever its length; after a right tab it + started wherever the editor's slightly narrower setting of it ended up: `LumaStudioInvoice`'s + IBAN stood 2.6pt right of the bank details over it. Such a value is now held by a left tab at + its start; a value set against its end, as a date at the right of a band is, keeps its right + tab. In Word the values of `LumaStudioInvoice`'s and `ConsultingInvoice`'s detail rows start + within 0.1pt of the page's. - **Text seated off its baseline sits there in Word, and text hanging below its band takes its overhang from the gap under it.** A paragraph set with `TextVerticalAlign.TOP`, `CENTER` or `BOTTOM` was written on its baseline: `LumaStudioInvoice`'s "INVOICE" is set against the top diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePair.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePair.java index 74c225e69..335445083 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePair.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePair.java @@ -4,10 +4,13 @@ import com.demcha.compose.document.layout.PlacedNode; 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.LayerStackNode; import com.demcha.compose.document.node.LineNode; import com.demcha.compose.document.node.ParagraphNode; import com.demcha.compose.document.node.PathNode; import com.demcha.compose.document.node.PolygonNode; +import com.demcha.compose.document.node.ShapeContainerNode; import com.demcha.compose.document.node.ShapeNode; import com.demcha.compose.document.node.TextAlign; import com.demcha.compose.document.node.TextDirection; @@ -50,7 +53,13 @@ private DocxLinePair() { * @param left the paragraph at the left * @param right the paragraph at the right * @param leftOffset where the left paragraph starts, from the overlay's left edge - * @param tabStop where the right paragraph ends, from the overlay's left edge + * @param tabStop where the right paragraph's tab stop stands, from the overlay's left + * edge: where its text starts when {@code fromItsStart}, where it ends + * otherwise + * @param fromItsStart whether the right text is set from its start — left-aligned in a + * layer placed from the left — so a left tab stop holds its start where + * the page puts it, and an editor setting it narrower or wider moves its + * end, as on the page, not its start * @param line the height the two lines take together, from the top of the higher * to the bottom of the lower * @param above from the overlay's top down to that top — negative where the text @@ -59,7 +68,7 @@ private DocxLinePair() { * text hangs below it */ record Pair(ParagraphNode left, ParagraphNode right, double leftOffset, double tabStop, - double line, double above, double below) { + boolean fromItsStart, double line, double above, double below) { } /** @@ -117,14 +126,40 @@ static Pair of(DocumentNode overlay, DocxLayoutMetrics layout) { double textTop = Math.max(first.placementY() + first.placementHeight(), second.placementY() + second.placementHeight()); double textBottom = Math.min(first.placementY(), second.placementY()); - return new Pair(firstIsLeft ? text.get(0) : text.get(1), firstIsLeft ? text.get(1) : text.get(0), + ParagraphNode rightText = firstIsLeft ? text.get(1) : text.get(0); + boolean fromItsStart = setFromItsStart(overlay, rightText); + return new Pair(firstIsLeft ? text.get(0) : text.get(1), rightText, Math.max(0, left[0] - box.placementX()), - right[1] - box.placementX(), + (fromItsStart ? right[0] : right[1]) - box.placementX(), + fromItsStart, textTop - textBottom, boxTop - textTop, textBottom - box.placementY()); } + /** + * Whether a paragraph's text is set from its start: aligned left in a layer placed from the + * overlay's left. A bank detail's value set at a column across the card starts there + * whatever its length; written after a right tab at its end, as a date at the right of a band + * is, it started wherever the editor's narrower setting of it ended up — "GB36 SRLG …" a few + * points right of the values over it. + */ + private static boolean setFromItsStart(DocumentNode overlay, ParagraphNode paragraph) { + if (paragraph.align() != null && paragraph.align() != TextAlign.LEFT) { + return false; + } + List layers = overlay instanceof LayerStackNode stack ? stack.layers() + : overlay instanceof ShapeContainerNode container ? container.layers() + : List.of(); + for (LayerStackNode.Layer layer : layers) { + if (layer.node() == paragraph) { + return layer.align() == LayerAlign.TOP_LEFT || layer.align() == LayerAlign.CENTER_LEFT + || layer.align() == LayerAlign.BOTTOM_LEFT; + } + } + return false; + } + /** * Where across the page a one-line paragraph's text runs: its line, placed in its box by * its alignment. A paragraph's box is often as wide as the band while its text is not — 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 31af5e753..71815b443 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 @@ -4061,7 +4061,9 @@ private void writeLinePair(XWPFDocument document, DocumentNode overlay, DocxLine } CTPPr properties = para.getCTP().isSetPPr() ? para.getCTP().getPPr() : para.getCTP().addNewPPr(); CTTabStop tab = (properties.isSetTabs() ? properties.getTabs() : properties.addNewTabs()).addNewTab(); - tab.setVal(STTabJc.RIGHT); + // A right text set from its start holds that start; one set against its end, as a date + // at the right of a band is, holds its end (see DocxLinePair.Pair#fromItsStart). + tab.setVal(pair.fromItsStart() ? STTabJc.LEFT : STTabJc.RIGHT); tab.setPos(BigInteger.valueOf(toTwips(lineStart + pair.tabStop()))); // Each half is still the paragraph it was: its outline level, its bookmark around its // own text, and whether it keeps with what follows. diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePairTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePairTest.java index cc1bed48a..1f55805c9 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePairTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePairTest.java @@ -46,6 +46,29 @@ void aTitleAtTheLeftAndDatesAtTheRightAreOneLineSplitByARightTab() throws Except } } + @Test + void aValueSetFromItsStartIsHeldThereByALeftTab() throws Exception { + // A bank detail's value starts at a column across the card whatever its length: a right + // tab at its end let an editor's narrower setting of a long IBAN start it further right. + DocumentNode row = new com.demcha.compose.document.dsl.ShapeContainerBuilder() + .name("BankRow") + .rectangle(CONTENT, 14) + .clipPolicy(ClipPolicy.OVERFLOW_VISIBLE) + .position(paragraph("IBAN", TextAlign.LEFT), 0, 0, LayerAlign.CENTER_LEFT) + .position(paragraph("GB36 SRLG 6083 7198 7654 32", TextAlign.LEFT), 120, 0, LayerAlign.CENTER_LEFT) + .build(); + try (XWPFDocument document = export(row)) { + XWPFParagraph line = written(document).get(0); + CTPPr properties = line.getCTP().getPPr(); + + assertThat(line.getText()).isEqualTo("IBAN\tGB36 SRLG 6083 7198 7654 32"); + assertThat(properties.getTabs().getTabArray(0).getVal().toString()).isEqualTo("left"); + assertThat(DocxTwips.of(properties.getTabs().getTabArray(0).getPos())) + .as("where the value starts on the page") + .isCloseTo(120L * 20, org.assertj.core.data.Offset.offset(2L)); + } + } + @Test void aPairInALayerStackIsOneLineTooNotABandOfTwo() throws Exception { try (XWPFDocument document = export(new com.demcha.compose.document.dsl.LayerStackBuilder() From 89355fc9b541a8db689d04f8d591fe8a7aecbe25 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Wed, 30 Sep 2026 10:50:24 +0100 Subject: [PATCH 2/2] fix(docx): keep the right tab for a value with no room past its end --- docs/recipes/docx-export.md | 7 +++- .../backend/semantic/docx/DocxLinePair.java | 27 +++++++----- .../semantic/docx/DocxSemanticBackend.java | 5 ++- .../semantic/docx/DocxLinePairTest.java | 41 +++++++++++++++++++ 4 files changed, 66 insertions(+), 14 deletions(-) diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 432cd5a25..13b612195 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -507,11 +507,14 @@ tint it was flattened to. Recorded, like the other two. spare on the side its text does not lean on. A block in `addAligned(...)` is held in the same way, to where the alignment puts it. Drawing is drawn as a shape where the page draws it (see "What is skipped") and does not count as content. -- **Text at the left and the right of one band → one line with a right tab.** A +- **Text at the left and the right of one band → one line split by a tab.** A container or a layer stack holding exactly two single-line paragraphs, level with one another and apart across the page — a CV entry's title and its dates — is one Word paragraph: the left paragraph's runs, a tab, the right one's, with a - right-aligned tab stop where the right text ends on the page. Its line runs from the + right-aligned tab stop where the right text ends on the page. A right text set from + its start — aligned left in a layer placed from the left, with room past its end, as + a bank detail's value is — takes a left-aligned stop where it starts instead, so an + editor setting it a little narrower moves its end, not its start. Its line runs from the top of the higher text to the bottom of the lower; text standing out of its band takes that much from the gap on that side, as it does on the page. A Word paragraph cannot reach above its cell, so text standing above a cell of a table's first row diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePair.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePair.java index 335445083..ea002f157 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePair.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePair.java @@ -25,8 +25,10 @@ * line, as two layers of one container. Word has no layers, so the export wrote the layers * one after the other, and the dates came out on a line of their own under the title: an * extra line in every entry, enough to push a one-page CV onto a second page. A line with - * text at the left and more at the right is what Word builds with a right-aligned tab stop, - * so that is what the pair is written as: the left paragraph's runs, a tab, the right one's.

+ * text at the left and more at the right is what Word builds with a tab stop, so that is what + * the pair is written as: the left paragraph's runs, a tab, the right one's. The stop is a + * right-aligned one at the right text's end, or a left-aligned one at its start when the text + * is set from its start (see {@link Pair#fromItsStart}).

* *

An overlay is taken as such a pair when it holds exactly two paragraphs, each laid out * on one line, level with one another on the page and apart across it, and nothing else but @@ -38,9 +40,10 @@ final class DocxLinePair { private static final double EDGE = 0.5; /** - * The room the two texts must leave between them, in points. An editor sets text a little - * wider or narrower than the page does, and a right-aligned tab the left text runs past - * sends the right one onto a line of its own — under an exact line height, out of sight. + * The room the two texts must leave between them, in points, and a text set from its start + * after its end. An editor sets text a little wider or narrower than the page does: a tab + * the left text runs past, or a right text run past the line's end, sends a word onto a line + * of its own — under an exact line height, out of sight. */ private static final double MIN_GAP = 4; @@ -57,9 +60,9 @@ private DocxLinePair() { * edge: where its text starts when {@code fromItsStart}, where it ends * otherwise * @param fromItsStart whether the right text is set from its start — left-aligned in a - * layer placed from the left — so a left tab stop holds its start where - * the page puts it, and an editor setting it narrower or wider moves its - * end, as on the page, not its start + * layer placed from the left, with room past its end — so a left tab + * stop holds its start where the page puts it, and an editor setting it + * narrower or wider moves its end, as on the page, not its start * @param line the height the two lines take together, from the top of the higher * to the bottom of the lower * @param above from the overlay's top down to that top — negative where the text @@ -127,7 +130,11 @@ static Pair of(DocumentNode overlay, DocxLayoutMetrics layout) { second.placementY() + second.placementHeight()); double textBottom = Math.min(first.placementY(), second.placementY()); ParagraphNode rightText = firstIsLeft ? text.get(1) : text.get(0); - boolean fromItsStart = setFromItsStart(overlay, rightText); + // Held by its start, its end runs free: an editor setting it wider breaks its last word + // onto a line of its own unless the overlay leaves room past it. Held by its end, it + // cannot, as before. + boolean fromItsStart = setFromItsStart(overlay, rightText) + && box.placementX() + box.placementWidth() - right[1] >= MIN_GAP; return new Pair(firstIsLeft ? text.get(0) : text.get(1), rightText, Math.max(0, left[0] - box.placementX()), (fromItsStart ? right[0] : right[1]) - box.placementX(), @@ -145,7 +152,7 @@ static Pair of(DocumentNode overlay, DocxLayoutMetrics layout) { * points right of the values over it. */ private static boolean setFromItsStart(DocumentNode overlay, ParagraphNode paragraph) { - if (paragraph.align() != null && paragraph.align() != TextAlign.LEFT) { + if (paragraph.align() != TextAlign.LEFT) { return false; } List layers = overlay instanceof LayerStackNode stack ? stack.layers() 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 71815b443..07630bc8e 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 @@ -4026,8 +4026,9 @@ private void applyInset(XWPFParagraph para) { } /** - * Writes an overlay's left and right paragraph as one line, the right one after a - * right-aligned tab stop where it ends on the page (see {@link DocxLinePair}). + * Writes an overlay's left and right paragraph as one line, the right one after a tab stop: + * a right-aligned one where it ends on the page, or a left-aligned one where it starts when + * it is set from its start (see {@link DocxLinePair}). * *

The line starts where the left paragraph does and runs from the top of the higher * text to the bottom of the lower, so the band keeps its place in the flow: the space diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePairTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePairTest.java index 1f55805c9..1241461bf 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePairTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePairTest.java @@ -69,6 +69,47 @@ void aValueSetFromItsStartIsHeldThereByALeftTab() throws Exception { } } + @Test + void aValueSetFromItsEndOrRightUpToTheEdgeKeepsItsRightTab() throws Exception { + // Placed from the right, the value's end is where the page holds it; placed from the + // left but running to the row's end, a left tab would leave an editor's wider setting of + // it no room, and its last word would go onto a line of its own. + String value = "GB36 SRLG 6083 7198 7654 32"; + assertThat(tabOf(new com.demcha.compose.document.dsl.ShapeContainerBuilder().name("FromTheRight") + .rectangle(CONTENT, 14).clipPolicy(ClipPolicy.OVERFLOW_VISIBLE) + .position(paragraph("IBAN", TextAlign.LEFT), 0, 0, LayerAlign.CENTER_LEFT) + .position(paragraph(value, TextAlign.LEFT), -30, 0, LayerAlign.CENTER_RIGHT) + .build())).as("placed from the right, room to spare past it").isEqualTo("right"); + double valueWidth = widthOf(value); + assertThat(tabOf(new com.demcha.compose.document.dsl.ShapeContainerBuilder().name("UpToTheEdge") + .rectangle(CONTENT, 14).clipPolicy(ClipPolicy.OVERFLOW_VISIBLE) + .position(paragraph("IBAN", TextAlign.LEFT), 0, 0, LayerAlign.CENTER_LEFT) + .position(paragraph(value, TextAlign.LEFT), CONTENT - valueWidth - 1, 0, LayerAlign.CENTER_LEFT) + .build())).as("from the left, a point short of the edge").isEqualTo("right"); + } + + /** The kind of the tab stop an overlay's pair is written with. */ + private static String tabOf(DocumentNode row) throws Exception { + try (XWPFDocument document = export(row)) { + return written(document).get(0).getCTP().getPPr().getTabs().getTabArray(0).getVal().toString(); + } + } + + /** How wide the page sets the text as a 9pt line, from its laid-out line. */ + private static double widthOf(String text) throws Exception { + try (com.demcha.compose.document.api.DocumentSession session = com.demcha.compose.GraphCompose.document() + .pageSize(PAGE_WIDTH, 600).margin(DocumentInsets.of(MARGIN)).create()) { + session.pageFlow(flow -> flow.add(paragraph(text, TextAlign.LEFT))); + return session.layoutGraph().fragments().stream() + .map(fragment -> fragment.payload()) + .filter(payload -> payload + instanceof com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload) + .map(payload -> ((com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload) payload) + .lines().get(0).width()) + .findFirst().orElseThrow(); + } + } + @Test void aPairInALayerStackIsOneLineTooNotABandOfTwo() throws Exception { try (XWPFDocument document = export(new com.demcha.compose.document.dsl.LayerStackBuilder()