From 4e56d2e0fb652327c3776427a99cc0a1298b565a Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Fri, 2 Oct 2026 14:32:48 +0100
Subject: [PATCH 1/2] fix(docx): indent a paragraph's lines by its blank
bulletOffset, as the page sets them
---
CHANGELOG.md | 7 +
.../architecture/backend-capability-matrix.md | 2 +-
docs/recipes/docx-export.md | 2 +-
.../libreoffice-windows-lines.tsv | 2 +
.../docx-fidelity/libreoffice-windows.tsv | 2 +-
.../docx-fidelity/word-windows-lines.tsv | 2 +
.../resources/docx-fidelity/word-windows.tsv | 2 +-
.../semantic/docx/DocxSemanticBackend.java | 76 +++++++++-
.../docx/DocxGeneratedIndentTest.java | 136 ++++++++++++++++++
9 files changed, 222 insertions(+), 9 deletions(-)
create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxGeneratedIndentTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6c70ee8c2..f31c42e76 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,13 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A paragraph's blank `bulletOffset` indents its lines in DOCX as it does on the page.**
+ The prefix was not written, so `EditorialProposal`'s bullets, whose wrapped lines the page
+ sets after three spaces, started those lines under the dot in Word, 11.6pt left of the
+ page's. A prefix of spaces is now the paragraph's indent — left and hanging for
+ `FROM_SECOND_LINE`, first line for `FIRST_LINE`, left for `ALL_LINES` — measured in the
+ paragraph's style. In Word the wrapped lines start within 0.1pt of the page's, and
+ `EditorialProposal` finds 130 of its 132 lines where it found 128.
- **A row's fixed columns keep their widths in DOCX when they add up to less than the row.**
The last column ran to the row's edge: `EditorialProposal`'s deliverables, a 194pt column in a
535pt band, came out 272pt wide, and Word set "Responsive design for desktop, tablet & mobile"
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index 652a02deb..cd6eddcd0 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -62,7 +62,7 @@ Payload records live in `core` under
| Capability (payload) | PDF (fixed) | PPTX (fixed) | DOCX (semantic) |
|---|---|---|---|
-| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; a `linkTarget` becomes a `w:hyperlink`, with a relationship for an address or `w:anchor` for one of the document's own anchors, and a run's own link wins over the paragraph's; a paragraph seated off its baseline (`TextVerticalAlign`) has its runs raised or lowered in the line (`w:position`) by the PDF backend's own correction (`ParagraphSeating`), one shift for the paragraph where the page seats each line by its own; Word and LibreOffice stand an exact line's baseline four fifths of the way down it whatever the face, where the page sets it the face's ascent down, so a paragraph whose face puts the two half a point or more apart — Spectral's, not Lato's — has its text moved to the page's baseline in the same position, matched at its middle line (not yet a list item's or a table text cell's; a picture among it moves with it in Word and stays on its own baseline in LibreOffice); lines a container stacks over one another tighter than their face each end halfway between their letters and the next line's (Word draws an exact line's text on screen only inside the line; its PDF export does not cut it), and the last layer of a shape container on one page, where its line runs past the foot, ends at the foot or below its letters; letters two lines share are split halfway so the page does not move, and a stack that holds a picture keeps its lines' own heights |
+| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; a `linkTarget` becomes a `w:hyperlink`, with a relationship for an address or `w:anchor` for one of the document's own anchors, and a run's own link wins over the paragraph's; a paragraph seated off its baseline (`TextVerticalAlign`) has its runs raised or lowered in the line (`w:position`) by the PDF backend's own correction (`ParagraphSeating`), one shift for the paragraph where the page seats each line by its own; Word and LibreOffice stand an exact line's baseline four fifths of the way down it whatever the face, where the page sets it the face's ascent down, so a paragraph whose face puts the two half a point or more apart — Spectral's, not Lato's — has its text moved to the page's baseline in the same position, matched at its middle line (not yet a list item's or a table text cell's; a picture among it moves with it in Word and stays on its own baseline in LibreOffice); lines a container stacks over one another tighter than their face each end halfway between their letters and the next line's (Word draws an exact line's text on screen only inside the line; its PDF export does not cut it), and the last layer of a shape container on one page, where its line runs past the foot, ends at the foot or below its letters; letters two lines share are split halfway so the page does not move, and a stack that holds a picture keeps its lines' own heights; a `bulletOffset` of spaces becomes the paragraph's indent (`w:ind` left, hanging or first line, by `indentStrategy`) in the flow and in cells, not yet over the flow or in a header or footer; one with letters in it is not written |
| List hanging indent — a marker column and a content column (`ListBuilder.hangingIndent(true)`, `markerGap(...)`) | ✅ marker and content emitted as separate `ParagraphFragmentPayload` fragments at the resolved `markerX` / `contentX` | ✅ the same fragments — the fixed-layout pipeline resolves the geometry before either backend sees it | ❌ ignored. `DocxSemanticBackend` exports a list as a real Word list — `numbering.xml`, `w:numPr` per item, the level carrying the marker — identically whether the flag is set or not; content and nesting are unaffected. Word places content at absolute indents and has no relative-advance primitive, so honouring the gap would mean measuring the marker, which the semantic backend has no font runtime to do. Measured and rejected: a reserved-column approximation renders a different gap than the one configured, and misaligns outright for a marker wider than the column. Word numbering does not honour the gap either and does not claim to — the level's marker column is a stated constant (180 twips, plus 120 per nesting level), chosen near the single space the old text form used |
| Inline code/badge chips (`InlineBackground` on text spans) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ⚠️ `DocxSemanticBackend` — the fill becomes the run's own `w:shd`, in a paragraph and in a list item alike, so a badge still reads as a badge. What Word has no way to say is the shape: shading covers the glyph box, so the corner radius and the padding that widens the run on the page are not in the file, and the export records both. A `w:shd` fill is opaque, so a translucent chip is flattened first against what this export wrote underneath it — the paragraph's shading, the cell's, or the page — so the chip agrees with the file it is in, which on a white page is the colour the PDF shows. It stops being translucent, and that is recorded with the rest |
| Inline images (`ParagraphImageSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ✅ `DocxSemanticBackend.writeInlinePicture` (a picture in its own run where it sits among the words, at its size, inside the run's or the paragraph's link; raised or lowered by `w:position` to where the page's alignment and `baselineOffset` put it, from the layout's measure of the paragraph's first line — in a list, the list's text on a line as tall as the item's own tallest picture; LibreOffice ignores `w:position` on a picture and stands it on the baseline, so a picture the export draws itself (icon, emoji, shape) that the page raises carries the rise as transparent rows and needs no `w:position`, while one the page lowers stands in LibreOffice higher than on the page by as much as the page lowers it — up to the text's descent for a centred icon as tall as its line; the editor clips a picture to an exact line height, so a paragraph holding a picture that leaves its text — past the ascent or the descent, in Word's placement or on the baseline — has its lines written at least the height the picture reaches, grown by the editor rather than clipped, every line of the paragraph since Word has one line height for it, and each as tall as the editor's font makes it — for 14pt text about 2.5pt taller than the page's in LibreOffice; a picture inside its text in both editors keeps the exact height; a paragraph of one line of text in a Word paragraph of its own, with room above for its pictures' reach, keeps an exact line at the page's height of it, the pictures set in it where the page puts them in Word and what their ink reaches past it taken from the gaps around it, and in LibreOffice a lowered picture there stands higher and loses what passes the line's top; its description is the text it stands for or empty) |
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index f682332dc..7c06169fa 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -67,7 +67,7 @@ creation date is real metadata.
| Document node | DOCX output |
|---|---|
-| Paragraphs | Word paragraphs with alignment, font, size, colour, bold/italic/underline; inline runs preserved; a `\n` in the text is a line break (`w:br`), which Word would otherwise read as a space; a table cell's `text("a\nb")` stays one line, as the page sets it |
+| Paragraphs | Word paragraphs with alignment, font, size, colour, bold/italic/underline; inline runs preserved; a `\n` in the text is a line break (`w:br`), which Word would otherwise read as a space; a table cell's `text("a\nb")` stays one line, as the page sets it; a `bulletOffset` of spaces is an indent — the wrapped lines (`FROM_SECOND_LINE`), the first (`FIRST_LINE`) or all of them start that far in, measured in the paragraph's style as the page measures it; a prefix with letters in it is not written, nor is a prefix in a paragraph written over the flow or in a header or footer |
| Lists | Real Word lists: a `numbering.xml` definition per list, `w:numPr` on each item, and the authored marker as the level's text. Nesting is a list level, so Enter continues the list and Tab demotes an item. See "What a list becomes" below for the kinds that stay plain paragraphs |
| Tables | Word tables, one cell per cell. Each cell states its own padding, on all four sides, so a row is as tall as the page draws it: as `w:tcMar`, and above and below partly in its paragraphs. Word and LibreOffice give every cell of a row the largest top and bottom margin of any cell in it, so a row's cells are written with its smallest, and the rest of a cell's padding above and below is space above its first paragraph and below its last (measured: a row whose day cells were padded 5.5pt above and 10.25pt below beside a label padded 0.75pt stood 60.3pt tall in both editors, where its tallest cell came to 46). A cell opening with a table has no paragraph above it to hold its padding, and a cell in a vertical merge has its bottom edge in another row: these keep their margins, and the row's comes down no lower than the largest of them. Its padding above and below gives up the room Word makes for the table's horizontal rules — half of a rule between two rows to each, the lower row's rule where the two differ, the rule above the table and the one below it whole to their row — which the page does not (measured: a 0.75pt rule made each row 0.75pt taller). A cell that holds nothing but an empty line — a row that is only a rule, its thickness the empty cell's font — has that line cut to the room its row leaves it, the page's row less the cell's own margins and border: the page draws the rule's borders across the line, and Word and LibreOffice keep them outside it and grow the row (measured: `CobaltRota`'s two rules under a 0.9pt border stood 0.9pt taller each). A line with letters, a picture or a paragraph border of its own keeps its height. A table that states no rule is written with the engine's default 1pt black rule, as the page draws it, not left on Word's thinner grid. Its `textAnchor` becomes `w:vAlign` and the paragraph's `w:jc`, with the engine's default — the vertical middle, on the left — where Word's is the top, so a line beside a taller neighbour sits where the page puts it and an amount column stays right-aligned. A cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A cell's lines are one paragraph with line breaks; when its style's `lineSpacing(...)` is above zero and it has more than one line, they are a paragraph each, with the spacing after every line but the last, since Word has no space between the lines of one paragraph but a taller line. A column sized to its content gets a point more than the page gives it, so the editor's font substitute cannot wrap its widest cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back". A table breaks across pages where the layout breaks it: every row the layout placed is kept whole (`w:cantSplit`), `repeatHeader(n)` rows repeat on each page (`w:tblHeader`) and stay with the row under them. Two tables in a row — rows included, since a row is carried as a table — are kept apart by a paragraph a tenth of a point tall, holding the rest of the gap between them: an editor joins two tables with nothing between them into one |
| Composed cells (`DocumentTableCell.node(...)`) | Written by the same writers that write that node anywhere else, so a cell built from an image, a list or a table carries it. A nested table is a real `w:tbl` followed by the paragraph Word requires a cell to end with — a hairline, which the paragraph written next in the cell takes over, so no empty line opens under the table, and whose mark is hidden where it is left at the cell's end holding nothing and no space, since LibreOffice lays it out — and takes the width of the column it sits in — the column's, not the one the page gives it, because the layout reports a composed cell's content under the owner's path |
diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv
index b144ecc91..4bee140f2 100644
--- a/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv
+++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv
@@ -3702,6 +3702,8 @@ proposal-editorial 1:5130aeada3db -43.12 majordeliverable.
proposal-editorial 1:ffce6ed7e6e5 -42.94 finalfilesandhandoffdeliveredupo
proposal-editorial 1:566f68df6dd8 -42.19 finalpayment.
proposal-editorial 1:06ebb5670fa8 -42.61 ownershipofallfinaldeliverables
+proposal-editorial 1:deda6d86d965 -41.86 transferstoasterahealthco.after
+proposal-editorial 1:aaa7d3e9affb -41.88 fullpaymentisreceived.
proposal-editorial 1:c1514c6cdad1 -44.29 acceptance
proposal-editorial 1:893663b06769 -43.26 bysigningbelow,youagreetothescop
proposal-editorial 1:df16232de5b9 -43.27 acceptedby:
diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv
index 364e41a97..40a15b85c 100644
--- a/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv
+++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv
@@ -56,7 +56,7 @@ letter-nordic_clean 1 1 15 15 0.16 0.38 0
letter-panel 1 1 13 13 0.15 0.21 0
letter-sidebar_portrait 1 1 12 12 0.43 0.51 0
letter-timeline_minimal 1 1 15 15 1.32 1.52 0
-proposal-editorial 2 2 132 127 43.26 45.15 70
+proposal-editorial 2 2 132 129 43.12 45.15 72
proposal-indigo 1 1 72 70 0.57 27.88 16
proposal-modern 1 1 44 44 0.41 0.75 0
proposal-northline 2 2 132 132 0.38 1.62 6
diff --git a/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv b/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv
index 801f21357..eaf270ddb 100644
--- a/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv
+++ b/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv
@@ -3801,6 +3801,8 @@ proposal-editorial 1:5414e5671cbd 1.81 projectandschedulethekick-off.
proposal-editorial 1:832db31f5c9a 1.96 includestworoundsofrevisionsper
proposal-editorial 1:5130aeada3db 2.71 majordeliverable.
proposal-editorial 1:06ebb5670fa8 3.16 ownershipofallfinaldeliverables
+proposal-editorial 1:deda6d86d965 4.06 transferstoasterahealthco.after
+proposal-editorial 1:aaa7d3e9affb 4.04 fullpaymentisreceived.
proposal-editorial 1:c1514c6cdad1 2.62 acceptance
proposal-editorial 1:893663b06769 2.26 bysigningbelow,youagreetothescop
proposal-editorial 1:df16232de5b9 2.41 acceptedby:
diff --git a/qa/src/test/resources/docx-fidelity/word-windows.tsv b/qa/src/test/resources/docx-fidelity/word-windows.tsv
index 1775a22fb..f8de405e9 100644
--- a/qa/src/test/resources/docx-fidelity/word-windows.tsv
+++ b/qa/src/test/resources/docx-fidelity/word-windows.tsv
@@ -56,7 +56,7 @@ letter-nordic_clean 1 1 15 15 0.16 0.35 0
letter-panel 1 1 13 13 0.17 0.25 0
letter-sidebar_portrait 1 1 12 12 0.41 0.52 0
letter-timeline_minimal 1 1 15 15 1.30 1.63 0
-proposal-editorial 2 2 132 128 0.50 2.09 19
+proposal-editorial 2 2 132 130 0.50 2.16 21
proposal-indigo 1 1 72 70 0.43 27.75 16
proposal-modern 1 1 44 44 0.35 0.73 0
proposal-northline 2 2 132 132 0.36 1.75 6
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 c58a84058..912545635 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
@@ -51,6 +51,7 @@
import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.style.DocumentRowColumn;
import com.demcha.compose.document.style.DocumentStroke;
+import com.demcha.compose.document.style.DocumentTextIndent;
import com.demcha.compose.document.style.DocumentTextStyle;
import com.demcha.compose.document.style.DocumentTextDecoration;
import com.demcha.compose.engine.components.content.text.TextDecoration;
@@ -5090,16 +5091,21 @@ private double styleLineHeight(DocumentTextStyle style) {
if (style == null) {
return 0;
}
+ try {
+ return styleMetrics().lineHeight(style);
+ } catch (RuntimeException unknownFace) {
+ return 0;
+ }
+ }
+
+ /** The measured fonts' metrics for a style, built when first asked for. */
+ private com.demcha.compose.document.chart.ChartTextMetrics styleMetrics() {
if (styleMetrics == null) {
styleMetrics = new com.demcha.compose.document.layout.ChartTextMetricsSupport(
new com.demcha.compose.engine.measurement.FontLibraryTextMeasurementSystem(
measuredFonts(), com.demcha.compose.engine.render.pdf.PdfFont.class));
}
- try {
- return styleMetrics.lineHeight(style);
- } catch (RuntimeException unknownFace) {
- return 0;
- }
+ return styleMetrics;
}
/**
@@ -5219,6 +5225,7 @@ private void writeParagraph(XWPFDocument document, ParagraphNode node) {
}
boolean standsOut = letTheLineStandOut(para, node, room);
boolean rightToLeft = applyParagraphProperties(para, node);
+ indentAsThePrefixDoes(para, node);
// A line that stands out was given its room, and a couple of points more.
if (!rightToLeft && !standsOut) {
measureAtWordsSize(para, node, room);
@@ -5234,6 +5241,65 @@ private void writeParagraph(XWPFDocument document, ParagraphNode node) {
lastWrittenParagraph = para;
}
+ /**
+ * Indents a paragraph's lines by the blank prefix the page sets them after.
+ *
+ * A paragraph's {@code bulletOffset} is text the page puts before its first line, its
+ * wrapped lines or both ({@code indentStrategy}), and none of it is in the paragraph's
+ * text. {@code EditorialProposal}'s bullets are a dot and three spaces before the text, and
+ * a prefix of three spaces before each wrapped line: in Word those lines started under the
+ * dot, 11.6pt left of the page's. A prefix of spaces is only a distance, so it is written as
+ * one — the left indent for the wrapped lines and the first line's difference from it —
+ * measured in the paragraph's style, not its runs', as the page measures it. It is written
+ * once a right-to-left paragraph's sides are turned, so it goes on {@code w:left}, the
+ * start of the flow in either direction.
+ *
+ * A prefix with letters in it is text the export does not write, and is left as it was.
+ * An auto-sized paragraph's prefix is measured at the size the export writes its text in,
+ * not the one the page fits it to.
+ */
+ private void indentAsThePrefixDoes(XWPFParagraph para, ParagraphNode node) {
+ String prefix = node.bulletOffset();
+ DocumentTextIndent strategy = node.indentStrategy();
+ if (prefix.isEmpty() || !prefix.isBlank() || strategy == DocumentTextIndent.NONE) {
+ return;
+ }
+ double width = styleWidth(node.textStyle(), prefix);
+ if (!(width > 0.01)) {
+ return;
+ }
+ boolean first = strategy == DocumentTextIndent.FIRST_LINE || strategy == DocumentTextIndent.ALL_LINES;
+ boolean wrapped = strategy == DocumentTextIndent.FROM_SECOND_LINE || strategy == DocumentTextIndent.ALL_LINES;
+ long wrappedTwips = wrapped ? toTwips(width) : 0;
+ long firstTwips = first ? toTwips(width) : 0;
+ CTPPr properties = para.getCTP().isSetPPr() ? para.getCTP().getPPr() : para.getCTP().addNewPPr();
+ CTInd indent = properties.isSetInd() ? properties.getInd() : properties.addNewInd();
+ if (wrappedTwips > 0) {
+ long left = twipsOf(indent.isSetLeft() ? indent.getLeft() : null);
+ indent.setLeft(BigInteger.valueOf(left + wrappedTwips));
+ }
+ if (firstTwips > wrappedTwips) {
+ indent.setFirstLine(BigInteger.valueOf(firstTwips - wrappedTwips));
+ } else if (wrappedTwips > firstTwips) {
+ indent.setHanging(BigInteger.valueOf(wrappedTwips - firstTwips));
+ }
+ }
+
+ /**
+ * The width the layout gives text set in a style, in points; 0 for no style, and for a
+ * face the measured fonts do not hold, as {@link #styleLineHeight} answers.
+ */
+ private double styleWidth(DocumentTextStyle style, String text) {
+ if (style == null) {
+ return 0;
+ }
+ try {
+ return styleMetrics().width(style, text);
+ } catch (RuntimeException unknownFace) {
+ return 0;
+ }
+ }
+
/**
* Sets the paragraph's lines to the height the engine measured them at.
*
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxGeneratedIndentTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxGeneratedIndentTest.java
new file mode 100644
index 000000000..1bb532775
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxGeneratedIndentTest.java
@@ -0,0 +1,136 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import com.demcha.compose.document.dsl.ParagraphBuilder;
+import com.demcha.compose.document.node.TextDirection;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentTextIndent;
+import com.demcha.compose.document.style.DocumentTextStyle;
+import com.demcha.compose.font.FontName;
+import org.apache.poi.xwpf.usermodel.XWPFDocument;
+import org.apache.poi.xwpf.usermodel.XWPFParagraph;
+import org.junit.jupiter.api.Test;
+import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTInd;
+
+import java.util.function.Consumer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A paragraph's blank prefix moves its lines in Word as far as it moves them on the page.
+ *
+ * {@code EditorialProposal}'s bullets set their wrapped lines after three spaces, under the
+ * text rather than under the dot; in Word those lines started under the dot, 11.6pt left.
+ * Three Helvetica spaces at 10pt are 3 × 0.278 × 10 = 8.34pt, 167 twips.
+ *
+ * @author Artem Demchyshyn
+ */
+class DocxGeneratedIndentTest {
+
+ private static final long THREE_SPACES = 167;
+ private static final String TEXT = "A line long enough to wrap onto a second one in the page's narrow measure";
+
+ @Test
+ void wrappedLinesStartAfterThePrefixAndTheFirstLineDoesNot() throws Exception {
+ CTInd indent = indentOf(p -> p.bulletOffset(" ").indentStrategy(DocumentTextIndent.FROM_SECOND_LINE));
+
+ assertThat(DocxTwips.of(indent.getLeft())).isEqualTo(THREE_SPACES);
+ assertThat(DocxTwips.of(indent.getHanging())).as("the first line back at the margin").isEqualTo(THREE_SPACES);
+ assertThat(indent.isSetFirstLine()).isFalse();
+ }
+
+ @Test
+ void onlyTheFirstLineStartsAfterAFirstLinePrefix() throws Exception {
+ CTInd indent = indentOf(p -> p.bulletOffset(" ").indentStrategy(DocumentTextIndent.FIRST_LINE));
+
+ assertThat(indent.isSetLeft()).isFalse();
+ assertThat(DocxTwips.of(indent.getFirstLine())).isEqualTo(THREE_SPACES);
+ assertThat(indent.isSetHanging()).isFalse();
+ }
+
+ @Test
+ void everyLineStartsAfterAPrefixOnAllOfThem() throws Exception {
+ CTInd indent = indentOf(p -> p.bulletOffset(" ").indentStrategy(DocumentTextIndent.ALL_LINES));
+
+ assertThat(DocxTwips.of(indent.getLeft())).isEqualTo(THREE_SPACES);
+ assertThat(indent.isSetHanging()).isFalse();
+ assertThat(indent.isSetFirstLine()).isFalse();
+ }
+
+ @Test
+ void thePrefixAddsToTheIndentOfTheContainerAroundIt() throws Exception {
+ try (XWPFDocument document = DocxExports.withLayout(300, 400, 20, page -> page
+ .addSection(section -> section.padding(new DocumentInsets(0, 0, 0, 20))
+ .addParagraph(p -> styled(p).bulletOffset(" ")
+ .indentStrategy(DocumentTextIndent.FROM_SECOND_LINE))))) {
+ CTInd indent = paragraph(document).getCTP().getPPr().getInd();
+
+ assertThat(DocxTwips.of(indent.getLeft())).isEqualTo(20 * 20L + THREE_SPACES);
+ assertThat(DocxTwips.of(indent.getHanging())).isEqualTo(THREE_SPACES);
+ }
+ }
+
+ @Test
+ void aRightToLeftParagraphIsIndentedFromItsFlowsStart() throws Exception {
+ CTInd indent = indentOf(p -> p.direction(TextDirection.RTL)
+ .bulletOffset(" ").indentStrategy(DocumentTextIndent.FROM_SECOND_LINE));
+
+ // In a w:bidi paragraph w:left is the start of the flow — the page's right — and the
+ // hanging indent is measured from it.
+ assertThat(DocxTwips.of(indent.getLeft())).as("the flow's start").isEqualTo(THREE_SPACES);
+ assertThat(indent.isSetRight()).isFalse();
+ assertThat(DocxTwips.of(indent.getHanging())).isEqualTo(THREE_SPACES);
+ }
+
+ @Test
+ void thePrefixIsMeasuredInTheParagraphsStyleNotItsRuns() throws Exception {
+ // EditorialProposal's case: the text is an inline run in a smaller face than the
+ // paragraph's, and the page sets the prefix in the paragraph's — three spaces at 14pt
+ // are 3 × 0.278 × 14 = 11.68pt, 234 twips.
+ DocumentTextStyle small = DocumentTextStyle.builder().fontName(FontName.HELVETICA).size(6).build();
+ try (XWPFDocument document = DocxExports.withLayout(300, 400, 20, page -> page
+ .addParagraph(p -> p.inlineText(TEXT, small)
+ .textStyle(DocumentTextStyle.builder().fontName(FontName.HELVETICA).size(14).build())
+ .bulletOffset(" ").indentStrategy(DocumentTextIndent.FROM_SECOND_LINE)))) {
+ CTInd indent = paragraph(document).getCTP().getPPr().getInd();
+
+ assertThat(DocxTwips.of(indent.getLeft())).isEqualTo(234L);
+ assertThat(DocxTwips.of(indent.getHanging())).isEqualTo(234L);
+ }
+ }
+
+ @Test
+ void aPrefixWithNoStrategyIsLeftAsItWas() throws Exception {
+ try (XWPFDocument document = DocxExports.withLayout(300, 400, 20, page -> page
+ .addParagraph(p -> styled(p).bulletOffset(" ").indentStrategy(DocumentTextIndent.NONE)))) {
+ assertThat(paragraph(document).getCTP().getPPr().isSetInd()).isFalse();
+ }
+ }
+
+ @Test
+ void aPrefixWithLettersInItIsLeftAsItWas() throws Exception {
+ try (XWPFDocument document = DocxExports.withLayout(300, 400, 20, page -> page
+ .addParagraph(p -> styled(p).bulletOffset("-").indentStrategy(DocumentTextIndent.ALL_LINES)))) {
+ XWPFParagraph paragraph = paragraph(document);
+
+ assertThat(paragraph.getCTP().getPPr().isSetInd()).isFalse();
+ }
+ }
+
+ private static CTInd indentOf(Consumer indent) throws Exception {
+ try (XWPFDocument document = DocxExports.withLayout(300, 400, 20, page -> page
+ .addParagraph(p -> indent.accept(styled(p))))) {
+ return (CTInd) paragraph(document).getCTP().getPPr().getInd().copy();
+ }
+ }
+
+ private static ParagraphBuilder styled(ParagraphBuilder paragraph) {
+ return paragraph.text(TEXT).textStyle(DocumentTextStyle.builder().fontName(FontName.HELVETICA).size(10).build());
+ }
+
+ private static XWPFParagraph paragraph(XWPFDocument document) {
+ return document.getParagraphs().stream()
+ .filter(paragraph -> paragraph.getText().startsWith("A line"))
+ .findFirst()
+ .orElseThrow();
+ }
+}
From 328d00c207d629afaedc636ca6cfcb5704355f82 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Fri, 2 Oct 2026 14:54:54 +0100
Subject: [PATCH 2/2] fix(docx): set wrapped lines after the spaces covering a
lettered bulletOffset, and take the Linux LibreOffice baseline from CI
---
CHANGELOG.md | 5 +-
.../architecture/backend-capability-matrix.md | 2 +-
docs/recipes/docx-export.md | 2 +-
.../docx-fidelity/libreoffice-linux-lines.tsv | 136 ++++++++++--------
.../docx-fidelity/libreoffice-linux.tsv | 4 +-
.../semantic/docx/DocxSemanticBackend.java | 43 ++++--
.../docx/DocxGeneratedIndentTest.java | 38 ++++-
7 files changed, 147 insertions(+), 83 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f31c42e76..f7643d9b0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,12 +8,13 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
-- **A paragraph's blank `bulletOffset` indents its lines in DOCX as it does on the page.**
+- **A paragraph's `bulletOffset` indents its lines in DOCX as it does on the page.**
The prefix was not written, so `EditorialProposal`'s bullets, whose wrapped lines the page
sets after three spaces, started those lines under the dot in Word, 11.6pt left of the
page's. A prefix of spaces is now the paragraph's indent — left and hanging for
`FROM_SECOND_LINE`, first line for `FIRST_LINE`, left for `ALL_LINES` — measured in the
- paragraph's style. In Word the wrapped lines start within 0.1pt of the page's, and
+ paragraph's style. A prefix with letters in it is still not written, but its wrapped lines
+ start after the spaces the page covers it with. In Word the wrapped lines start within 0.1pt of the page's, and
`EditorialProposal` finds 130 of its 132 lines where it found 128.
- **A row's fixed columns keep their widths in DOCX when they add up to less than the row.**
The last column ran to the row's edge: `EditorialProposal`'s deliverables, a 194pt column in a
diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md
index cd6eddcd0..ecadaac4e 100644
--- a/docs/architecture/backend-capability-matrix.md
+++ b/docs/architecture/backend-capability-matrix.md
@@ -62,7 +62,7 @@ Payload records live in `core` under
| Capability (payload) | PDF (fixed) | PPTX (fixed) | DOCX (semantic) |
|---|---|---|---|
-| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; a `linkTarget` becomes a `w:hyperlink`, with a relationship for an address or `w:anchor` for one of the document's own anchors, and a run's own link wins over the paragraph's; a paragraph seated off its baseline (`TextVerticalAlign`) has its runs raised or lowered in the line (`w:position`) by the PDF backend's own correction (`ParagraphSeating`), one shift for the paragraph where the page seats each line by its own; Word and LibreOffice stand an exact line's baseline four fifths of the way down it whatever the face, where the page sets it the face's ascent down, so a paragraph whose face puts the two half a point or more apart — Spectral's, not Lato's — has its text moved to the page's baseline in the same position, matched at its middle line (not yet a list item's or a table text cell's; a picture among it moves with it in Word and stays on its own baseline in LibreOffice); lines a container stacks over one another tighter than their face each end halfway between their letters and the next line's (Word draws an exact line's text on screen only inside the line; its PDF export does not cut it), and the last layer of a shape container on one page, where its line runs past the foot, ends at the foot or below its letters; letters two lines share are split halfway so the page does not move, and a stack that holds a picture keeps its lines' own heights; a `bulletOffset` of spaces becomes the paragraph's indent (`w:ind` left, hanging or first line, by `indentStrategy`) in the flow and in cells, not yet over the flow or in a header or footer; one with letters in it is not written |
+| Paragraph — pre-wrapped lines, runs, alignment (`ParagraphFragmentPayload`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` (one absolute, wrap-disabled frame per measured line) | ⚠️ semantic paragraphs (`DocxSemanticBackend`) — each run keeps its own style, falling back to the paragraph's when it has none; a `linkTarget` becomes a `w:hyperlink`, with a relationship for an address or `w:anchor` for one of the document's own anchors, and a run's own link wins over the paragraph's; a paragraph seated off its baseline (`TextVerticalAlign`) has its runs raised or lowered in the line (`w:position`) by the PDF backend's own correction (`ParagraphSeating`), one shift for the paragraph where the page seats each line by its own; Word and LibreOffice stand an exact line's baseline four fifths of the way down it whatever the face, where the page sets it the face's ascent down, so a paragraph whose face puts the two half a point or more apart — Spectral's, not Lato's — has its text moved to the page's baseline in the same position, matched at its middle line (not yet a list item's or a table text cell's; a picture among it moves with it in Word and stays on its own baseline in LibreOffice); lines a container stacks over one another tighter than their face each end halfway between their letters and the next line's (Word draws an exact line's text on screen only inside the line; its PDF export does not cut it), and the last layer of a shape container on one page, where its line runs past the foot, ends at the foot or below its letters; letters two lines share are split halfway so the page does not move, and a stack that holds a picture keeps its lines' own heights; a `bulletOffset` of spaces becomes the paragraph's indent (`w:ind` left, hanging or first line, by `indentStrategy`) in the flow and in cells, not yet over the flow, in an overlay's left-and-right pair or in a header or footer; one with letters in it is not written, its wrapped lines still set after the spaces that cover it |
| List hanging indent — a marker column and a content column (`ListBuilder.hangingIndent(true)`, `markerGap(...)`) | ✅ marker and content emitted as separate `ParagraphFragmentPayload` fragments at the resolved `markerX` / `contentX` | ✅ the same fragments — the fixed-layout pipeline resolves the geometry before either backend sees it | ❌ ignored. `DocxSemanticBackend` exports a list as a real Word list — `numbering.xml`, `w:numPr` per item, the level carrying the marker — identically whether the flag is set or not; content and nesting are unaffected. Word places content at absolute indents and has no relative-advance primitive, so honouring the gap would mean measuring the marker, which the semantic backend has no font runtime to do. Measured and rejected: a reserved-column approximation renders a different gap than the one configured, and misaligns outright for a marker wider than the column. Word numbering does not honour the gap either and does not claim to — the level's marker column is a stated constant (180 twips, plus 120 per nesting level), chosen near the single space the old text form used |
| Inline code/badge chips (`InlineBackground` on text spans) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ⚠️ `DocxSemanticBackend` — the fill becomes the run's own `w:shd`, in a paragraph and in a list item alike, so a badge still reads as a badge. What Word has no way to say is the shape: shading covers the glyph box, so the corner radius and the padding that widens the run on the page are not in the file, and the export records both. A `w:shd` fill is opaque, so a translucent chip is flattened first against what this export wrote underneath it — the paragraph's shading, the cell's, or the page — so the chip agrees with the file it is in, which on a white page is the colour the PDF shows. It stops being translucent, and that is recorded with the rest |
| Inline images (`ParagraphImageSpan`) | ✅ `PdfParagraphFragmentRenderHandler` | ✅ `PptxParagraphFragmentRenderHandler` | ✅ `DocxSemanticBackend.writeInlinePicture` (a picture in its own run where it sits among the words, at its size, inside the run's or the paragraph's link; raised or lowered by `w:position` to where the page's alignment and `baselineOffset` put it, from the layout's measure of the paragraph's first line — in a list, the list's text on a line as tall as the item's own tallest picture; LibreOffice ignores `w:position` on a picture and stands it on the baseline, so a picture the export draws itself (icon, emoji, shape) that the page raises carries the rise as transparent rows and needs no `w:position`, while one the page lowers stands in LibreOffice higher than on the page by as much as the page lowers it — up to the text's descent for a centred icon as tall as its line; the editor clips a picture to an exact line height, so a paragraph holding a picture that leaves its text — past the ascent or the descent, in Word's placement or on the baseline — has its lines written at least the height the picture reaches, grown by the editor rather than clipped, every line of the paragraph since Word has one line height for it, and each as tall as the editor's font makes it — for 14pt text about 2.5pt taller than the page's in LibreOffice; a picture inside its text in both editors keeps the exact height; a paragraph of one line of text in a Word paragraph of its own, with room above for its pictures' reach, keeps an exact line at the page's height of it, the pictures set in it where the page puts them in Word and what their ink reaches past it taken from the gaps around it, and in LibreOffice a lowered picture there stands higher and loses what passes the line's top; its description is the text it stands for or empty) |
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 7c06169fa..83152fa9c 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -67,7 +67,7 @@ creation date is real metadata.
| Document node | DOCX output |
|---|---|
-| Paragraphs | Word paragraphs with alignment, font, size, colour, bold/italic/underline; inline runs preserved; a `\n` in the text is a line break (`w:br`), which Word would otherwise read as a space; a table cell's `text("a\nb")` stays one line, as the page sets it; a `bulletOffset` of spaces is an indent — the wrapped lines (`FROM_SECOND_LINE`), the first (`FIRST_LINE`) or all of them start that far in, measured in the paragraph's style as the page measures it; a prefix with letters in it is not written, nor is a prefix in a paragraph written over the flow or in a header or footer |
+| Paragraphs | Word paragraphs with alignment, font, size, colour, bold/italic/underline; inline runs preserved; a `\n` in the text is a line break (`w:br`), which Word would otherwise read as a space; a table cell's `text("a\nb")` stays one line, as the page sets it; a `bulletOffset` of spaces is an indent — the wrapped lines (`FROM_SECOND_LINE`), the first (`FIRST_LINE`) or all of them start that far in, measured in the paragraph's style as the page measures it; a prefix with letters in it is not written, but the wrapped lines still start after the spaces the page covers it with; no prefix is applied to a paragraph written over the flow, as one of an overlay's left-and-right pair, or in a header or footer |
| Lists | Real Word lists: a `numbering.xml` definition per list, `w:numPr` on each item, and the authored marker as the level's text. Nesting is a list level, so Enter continues the list and Tab demotes an item. See "What a list becomes" below for the kinds that stay plain paragraphs |
| Tables | Word tables, one cell per cell. Each cell states its own padding, on all four sides, so a row is as tall as the page draws it: as `w:tcMar`, and above and below partly in its paragraphs. Word and LibreOffice give every cell of a row the largest top and bottom margin of any cell in it, so a row's cells are written with its smallest, and the rest of a cell's padding above and below is space above its first paragraph and below its last (measured: a row whose day cells were padded 5.5pt above and 10.25pt below beside a label padded 0.75pt stood 60.3pt tall in both editors, where its tallest cell came to 46). A cell opening with a table has no paragraph above it to hold its padding, and a cell in a vertical merge has its bottom edge in another row: these keep their margins, and the row's comes down no lower than the largest of them. Its padding above and below gives up the room Word makes for the table's horizontal rules — half of a rule between two rows to each, the lower row's rule where the two differ, the rule above the table and the one below it whole to their row — which the page does not (measured: a 0.75pt rule made each row 0.75pt taller). A cell that holds nothing but an empty line — a row that is only a rule, its thickness the empty cell's font — has that line cut to the room its row leaves it, the page's row less the cell's own margins and border: the page draws the rule's borders across the line, and Word and LibreOffice keep them outside it and grow the row (measured: `CobaltRota`'s two rules under a 0.9pt border stood 0.9pt taller each). A line with letters, a picture or a paragraph border of its own keeps its height. A table that states no rule is written with the engine's default 1pt black rule, as the page draws it, not left on Word's thinner grid. Its `textAnchor` becomes `w:vAlign` and the paragraph's `w:jc`, with the engine's default — the vertical middle, on the left — where Word's is the top, so a line beside a taller neighbour sits where the page puts it and an amount column stays right-aligned. A cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A cell's lines are one paragraph with line breaks; when its style's `lineSpacing(...)` is above zero and it has more than one line, they are a paragraph each, with the spacing after every line but the last, since Word has no space between the lines of one paragraph but a taller line. A column sized to its content gets a point more than the page gives it, so the editor's font substitute cannot wrap its widest cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back". A table breaks across pages where the layout breaks it: every row the layout placed is kept whole (`w:cantSplit`), `repeatHeader(n)` rows repeat on each page (`w:tblHeader`) and stay with the row under them. Two tables in a row — rows included, since a row is carried as a table — are kept apart by a paragraph a tenth of a point tall, holding the rest of the gap between them: an editor joins two tables with nothing between them into one |
| Composed cells (`DocumentTableCell.node(...)`) | Written by the same writers that write that node anywhere else, so a cell built from an image, a list or a table carries it. A nested table is a real `w:tbl` followed by the paragraph Word requires a cell to end with — a hairline, which the paragraph written next in the cell takes over, so no empty line opens under the table, and whose mark is hidden where it is left at the cell's end holding nothing and no space, since LibreOffice lays it out — and takes the width of the column it sits in — the column's, not the one the page gives it, because the layout reports a composed cell's content under the owner's path |
diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv
index 64ad7aa61..b3693ce8d 100644
--- a/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv
+++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv
@@ -3120,7 +3120,7 @@ invoice-obsidian 0:7b14b8d2c5c4 -0.03 page1of1
invoice-payments 0:b6c4ac412ac8 -0.02 meridian
invoice-payments 0:52d6e3de4fa0 0.41 invoice
invoice-payments 0:c0cc75b9cfdc -0.22 meridianpaymentsltd.
-invoice-payments 0:c0cc75b9cfdc#1 -24.61 meridianpaymentsltd.
+invoice-payments 0:c0cc75b9cfdc#1 -5.61 meridianpaymentsltd.
invoice-payments 0:5eeaa101da6c -0.19 1harbourexchange
invoice-payments 0:e3f6090f249b -0.23 canarywharf
invoice-payments 0:1bb9adbf32ff -0.26 londone149ge
@@ -3137,7 +3137,7 @@ invoice-payments 0:fb854bac39c8 -0.16 paymentterms
invoice-payments 0:ac10f8c16320 -0.19 duedate
invoice-payments 0:073c1634c496 -0.21 status
invoice-payments 0:052ff4dff688 -0.04 inv-2025-05-10472
-invoice-payments 0:052ff4dff688#1 -24.81 inv-2025-05-10472
+invoice-payments 0:052ff4dff688#1 -5.81 inv-2025-05-10472
invoice-payments 0:23a4c624cbda -0.01 10may2025
invoice-payments 0:7b052c343b27 0.02 1-30april2025
invoice-payments 0:018052c1dd59 0.04 acct_4q8xtb2mkp
@@ -3160,62 +3160,66 @@ invoice-payments 0:1a7b308d8df6 0.04 quantity
invoice-payments 0:d1062d072acd 0.04 unitprice
invoice-payments 0:7c512b48ba8a 0.04 vat
invoice-payments 0:2da123241332 0.04 amount(gbp)
-invoice-payments 0:3ab6c1099bcc 0.23 subscriptionbilling
-invoice-payments 0:96d6b62c061d -4.80 £250.00
-invoice-payments 0:96d6b62c061d#1 -4.79 £250.00
-invoice-payments 0:dc934fe30a94 -4.80 20%
-invoice-payments 0:dc934fe30a94#1 -14.35 20%
-invoice-payments 0:dc934fe30a94#2 11.02 20%
-invoice-payments 0:dc934fe30a94#3 9.66 20%
-invoice-payments 0:dc934fe30a94#4 8.30 20%
-invoice-payments 0:dc934fe30a94#5 -116.26 20%
-invoice-payments 0:0a9acaea6477 -9.32 paymentprocessing
-invoice-payments 0:351704ebff51 -14.35 £1,180.00
-invoice-payments 0:351704ebff51#1 -14.34 £1,180.00
-invoice-payments 0:b3bc34f90a55 -19.78 fraudscreening
-invoice-payments 0:ed051c6a1942 -19.74 advancedfraudscreeningandrules
-invoice-payments 0:a0a787efd867 -19.78 £320.00
-invoice-payments 0:a0a787efd867#1 -19.77 £320.00
-invoice-payments 0:34b75c1d0234 -21.14 companyformation
-invoice-payments 0:bf45c494a8dc -21.10 incorporationandcompliancefiling
-invoice-payments 0:629184210581 -21.14 £180.00
-invoice-payments 0:629184210581#1 -21.13 £180.00
-invoice-payments 0:9427a01801d2 -22.50 in-personterminals
-invoice-payments 0:882be821cf2e -22.46 terminalrentalandfirmwareupdates
-invoice-payments 0:3c4459a5e4ec -22.50 £39.00
-invoice-payments 0:5d16afda896e -22.49 £156.00
-invoice-payments 0:0036ebf2cfb2 -23.86 prioritysupport
-invoice-payments 0:7ef1d2ff5e3a -23.82 namedengineer,one-hourresponse
-invoice-payments 0:cf02270203a4 -23.86 £100.00
-invoice-payments 0:cf02270203a4#1 -23.85 £100.00
-invoice-payments 0:3c7ade679fdf -24.58 paymentdetails
-invoice-payments 0:182748d2fece -24.54 bankname
-invoice-payments 0:758f4a6f175f -24.53 accountname
-invoice-payments 0:10aeacde20ff -24.52 sortcode
-invoice-payments 0:f92a9c925024 -24.51 accountnumber
-invoice-payments 0:d53cd2cae33d -24.50 iban
-invoice-payments 0:3f695692d5f1 -24.49 swift/bic
-invoice-payments 0:52367a6622b1 -24.48 reference
-invoice-payments 0:c66da4639881 -24.57 harbourbankplc
-invoice-payments 0:2cd71c3c2603 -24.65 40-02-50
-invoice-payments 0:8fb8a81278c4 -24.69 71295408
-invoice-payments 0:48617b6d05d4 -24.73 gb29hrbk40025071295408
-invoice-payments 0:6929e6db6e1f -24.77 hrbkgb2l
-invoice-payments 0:e4c4788968b2 -24.49 subtotal(excl.vat)
-invoice-payments 0:7ed180496d4e -24.48 vatat20%
-invoice-payments 0:751cbab81a3a -24.45 £2,186.00
-invoice-payments 0:9a5256946abd -24.44 £437.20
-invoice-payments 0:57a66d21eb1b -24.50 totaldue
-invoice-payments 0:5eb0b17220fa -24.44 dueby14june2025
-invoice-payments 0:30d896eb989f -24.70 £2,623.20
-invoice-payments 0:ab5aa97074c4 -24.29 notes
-invoice-payments 0:241bcf576ac5 -24.27 thankyouforyourbusiness.thisinvo
-invoice-payments 0:f912ca46bf39 -24.29 latepaymentmayincurinterestatthe
-invoice-payments 0:73a09fec2c1a -24.25 paymentdueby14june2025
-invoice-payments 0:79df6c5bbc16 -24.27 pleaseincludetheinvoicenumberasp
-invoice-payments 0:28b3008f693b -24.31 questions?we'reheretohelp.
-invoice-payments 0:5b88ed40a689 -24.28 support@meridianpayments.example
-invoice-payments 0:2e3891f6ba2f -24.27 39661900
+invoice-payments 0:3ab6c1099bcc -0.02 subscriptionbilling
+invoice-payments 0:c8e69cd6e074 0.12 subscriptionmanagementandrecurri
+invoice-payments 0:0c95c7ece1ce -0.04 billing
+invoice-payments 0:96d6b62c061d -0.05 £250.00
+invoice-payments 0:96d6b62c061d#1 -0.04 £250.00
+invoice-payments 0:dc934fe30a94 -0.05 20%
+invoice-payments 0:dc934fe30a94#1 -0.10 20%
+invoice-payments 0:dc934fe30a94#2 -0.78 20%
+invoice-payments 0:dc934fe30a94#3 -2.14 20%
+invoice-payments 0:dc934fe30a94#4 -3.50 20%
+invoice-payments 0:dc934fe30a94#5 -4.86 20%
+invoice-payments 0:0a9acaea6477 -0.07 paymentprocessing
+invoice-payments 0:b8a3c21545b4 0.07 onlinecardpaymentsprocessing
+invoice-payments 0:b42561a38a06 -0.09 (volumetier)
+invoice-payments 0:351704ebff51 -0.10 £1,180.00
+invoice-payments 0:351704ebff51#1 -0.09 £1,180.00
+invoice-payments 0:b3bc34f90a55 -0.78 fraudscreening
+invoice-payments 0:ed051c6a1942 -0.74 advancedfraudscreeningandrules
+invoice-payments 0:a0a787efd867 -0.78 £320.00
+invoice-payments 0:a0a787efd867#1 -0.77 £320.00
+invoice-payments 0:34b75c1d0234 -2.14 companyformation
+invoice-payments 0:bf45c494a8dc -2.10 incorporationandcompliancefiling
+invoice-payments 0:629184210581 -2.14 £180.00
+invoice-payments 0:629184210581#1 -2.13 £180.00
+invoice-payments 0:9427a01801d2 -3.50 in-personterminals
+invoice-payments 0:882be821cf2e -3.46 terminalrentalandfirmwareupdates
+invoice-payments 0:3c4459a5e4ec -3.50 £39.00
+invoice-payments 0:5d16afda896e -3.49 £156.00
+invoice-payments 0:0036ebf2cfb2 -4.86 prioritysupport
+invoice-payments 0:7ef1d2ff5e3a -4.82 namedengineer,one-hourresponse
+invoice-payments 0:cf02270203a4 -4.86 £100.00
+invoice-payments 0:cf02270203a4#1 -4.85 £100.00
+invoice-payments 0:3c7ade679fdf -5.58 paymentdetails
+invoice-payments 0:182748d2fece -5.54 bankname
+invoice-payments 0:758f4a6f175f -5.53 accountname
+invoice-payments 0:10aeacde20ff -5.52 sortcode
+invoice-payments 0:f92a9c925024 -5.51 accountnumber
+invoice-payments 0:d53cd2cae33d -5.50 iban
+invoice-payments 0:3f695692d5f1 -5.49 swift/bic
+invoice-payments 0:52367a6622b1 -5.48 reference
+invoice-payments 0:c66da4639881 -5.57 harbourbankplc
+invoice-payments 0:2cd71c3c2603 -5.65 40-02-50
+invoice-payments 0:8fb8a81278c4 -5.69 71295408
+invoice-payments 0:48617b6d05d4 -5.73 gb29hrbk40025071295408
+invoice-payments 0:6929e6db6e1f -5.77 hrbkgb2l
+invoice-payments 0:e4c4788968b2 -5.49 subtotal(excl.vat)
+invoice-payments 0:7ed180496d4e -5.48 vatat20%
+invoice-payments 0:751cbab81a3a -5.45 £2,186.00
+invoice-payments 0:9a5256946abd -5.44 £437.20
+invoice-payments 0:57a66d21eb1b -5.50 totaldue
+invoice-payments 0:5eb0b17220fa -5.44 dueby14june2025
+invoice-payments 0:30d896eb989f -5.70 £2,623.20
+invoice-payments 0:ab5aa97074c4 -5.29 notes
+invoice-payments 0:241bcf576ac5 -5.27 thankyouforyourbusiness.thisinvo
+invoice-payments 0:f912ca46bf39 -5.29 latepaymentmayincurinterestatthe
+invoice-payments 0:73a09fec2c1a -5.25 paymentdueby14june2025
+invoice-payments 0:79df6c5bbc16 -5.27 pleaseincludetheinvoicenumberasp
+invoice-payments 0:28b3008f693b -5.31 questions?we'reheretohelp.
+invoice-payments 0:5b88ed40a689 -5.28 support@meridianpayments.example
+invoice-payments 0:2e3891f6ba2f -5.27 39661900
invoice-payments 0:7b14b8d2c5c4 -0.04 page1of1
invoice-platform 0:9817f28442bf 0.04 auroracloud
invoice-platform 0:9817f28442bf#1 -1.41 auroracloud
@@ -3717,7 +3721,11 @@ proposal-editorial 0:e8228bfeba0e 0.15 foundationforfuture
proposal-editorial 0:1fa35c7a1402 -0.85 growth.
proposal-editorial 0:bc981abcdf59 -1.33 scopeofwork
proposal-editorial 0:28450edd9a4f -0.25 discovery&research
+proposal-editorial 0:ce9012b74aa2 0.00 stakeholderinterviews,brandaudit
+proposal-editorial 0:730c3e278933 -0.28 audienceinsightstoinformstrategy
proposal-editorial 0:12bff3036b66 -0.16 visualidentityrefinement
+proposal-editorial 0:937ad523a90c 0.09 refinelogosystem,colorpalette,ty
+proposal-editorial 0:9b232e66f46c -0.19 guidelinesforacohesive,modernide
proposal-editorial 0:fc5134bf5fd1 -0.07 websiteux/uidesign
proposal-editorial 0:5d58b01c7096 0.18 designaresponsive,accessibility-
proposal-editorial 0:5d6068f6a879 -0.10 navigationandclearconversionpath
@@ -3725,6 +3733,8 @@ proposal-editorial 0:32001e20b2ed 0.02 contentstructure&messaging
proposal-editorial 0:b3c948ec3e36 0.27 definesitearchitecture,keymessag
proposal-editorial 0:231428fde7aa -0.01 communicatevalueandbuildtrust.
proposal-editorial 0:8c7703bb6fb7 0.11 launchsupport
+proposal-editorial 0:34f9fe468474 0.36 developerhandoff,qasupport,train
+proposal-editorial 0:2b4cf15f6f16 0.08 toensureasmoothlaunch.
proposal-editorial 0:026890c58de9 0.01 northlinestudio|northlinestudio.
proposal-editorial 1:e35c32bb5ed8 -46.41 deliverables
proposal-editorial 1:23eb79a19b39 -45.38 brandstrategysummary
@@ -3732,9 +3742,11 @@ proposal-editorial 1:aaa2a6073d69 -45.38 refinedlogosuite&brandguidelines
proposal-editorial 1:d626f7532564 -45.38 color,typography&designsystem
proposal-editorial 1:018ec557a513 -45.38 websiteuxwireframes
proposal-editorial 1:5ed4970fa285 -45.38 high-fidelitywebsitedesigns
-proposal-editorial 1:8b712e7e93bf -57.80 contentstrategy&pagetemplates
-proposal-editorial 1:e9bf06d9b130 -57.80 developer-readyassets&specificat
-proposal-editorial 1:e93e7ba95ceb -57.80 launchchecklist&trainingsession
+proposal-editorial 1:a579d6d1c12e -45.48 responsivedesignfordesktop,table
+proposal-editorial 1:d524c1a0811d -45.13 mobile
+proposal-editorial 1:8b712e7e93bf -44.40 contentstrategy&pagetemplates
+proposal-editorial 1:e9bf06d9b130 -44.40 developer-readyassets&specificat
+proposal-editorial 1:e93e7ba95ceb -44.40 launchchecklist&trainingsession
proposal-editorial 1:94d192b3a326 -46.32 timeline
proposal-editorial 1:02195b8e9896 -45.21 phase
proposal-editorial 1:c51faa148557 -45.21 focus
@@ -3789,6 +3801,8 @@ proposal-editorial 1:5130aeada3db -43.72 majordeliverable.
proposal-editorial 1:ffce6ed7e6e5 -43.54 finalfilesandhandoffdeliveredupo
proposal-editorial 1:566f68df6dd8 -42.79 finalpayment.
proposal-editorial 1:06ebb5670fa8 -43.21 ownershipofallfinaldeliverables
+proposal-editorial 1:deda6d86d965 -42.46 transferstoasterahealthco.after
+proposal-editorial 1:aaa7d3e9affb -42.48 fullpaymentisreceived.
proposal-editorial 1:c1514c6cdad1 -45.49 acceptance
proposal-editorial 1:893663b06769 -44.46 bysigningbelow,youagreetothescop
proposal-editorial 1:df16232de5b9 -44.47 acceptedby:
diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv
index 5d42ea2b0..64fe983b3 100644
--- a/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv
+++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv
@@ -37,7 +37,7 @@ invoice-metered 1 1 99 99 0.91 10.00 48
invoice-modern 1 1 54 54 0.07 0.20 0
invoice-modern_long 2 2 162 162 0.20 0.27 0
invoice-obsidian 1 1 81 81 1.12 3.00 20
-invoice-payments 1 1 104 100 14.34 24.54 57
+invoice-payments 1 1 104 104 0.32 5.53 45
invoice-platform 1 1 111 111 0.39 0.52 0
invoice-subscription 1 1 81 81 0.42 0.73 0
invoice-workspace 1 1 90 90 0.17 0.31 0
@@ -56,7 +56,7 @@ letter-nordic_clean 1 1 15 15 0.16 0.38 0
letter-panel 1 1 13 13 0.15 0.21 0
letter-sidebar_portrait 1 1 12 12 0.43 0.51 0
letter-timeline_minimal 1 1 15 15 1.32 1.52 0
-proposal-editorial 2 2 132 119 43.85 45.40 68
+proposal-editorial 2 2 132 129 43.72 45.38 72
proposal-indigo 1 1 72 70 0.57 27.88 16
proposal-modern 1 1 44 44 0.50 0.75 0
proposal-northline 2 2 132 132 0.28 0.77 5
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 912545635..af8a039be 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
@@ -5254,24 +5254,27 @@ private void writeParagraph(XWPFDocument document, ParagraphNode node) {
* once a right-to-left paragraph's sides are turned, so it goes on {@code w:left}, the
* start of the flow in either direction.
*
- * A prefix with letters in it is text the export does not write, and is left as it was.
- * An auto-sized paragraph's prefix is measured at the size the export writes its text in,
- * not the one the page fits it to.
+ * A prefix with letters in it is drawn only before the first line; the wrapped lines
+ * start after as many spaces as cover it ({@link #spacesCovering}), a distance written as any
+ * other. Its letters are text the export does not write, so the first line is not moved for
+ * them. An auto-sized paragraph's prefix is measured at the size the export writes its text
+ * in, not the one the page fits it to.
*/
private void indentAsThePrefixDoes(XWPFParagraph para, ParagraphNode node) {
String prefix = node.bulletOffset();
DocumentTextIndent strategy = node.indentStrategy();
- if (prefix.isEmpty() || !prefix.isBlank() || strategy == DocumentTextIndent.NONE) {
+ if (prefix.isEmpty() || strategy == DocumentTextIndent.NONE) {
return;
}
- double width = styleWidth(node.textStyle(), prefix);
- if (!(width > 0.01)) {
+ boolean blank = prefix.isBlank();
+ boolean first = blank && (strategy == DocumentTextIndent.FIRST_LINE || strategy == DocumentTextIndent.ALL_LINES);
+ boolean wrapped = strategy == DocumentTextIndent.FROM_SECOND_LINE || strategy == DocumentTextIndent.ALL_LINES;
+ DocumentTextStyle style = node.textStyle();
+ long wrappedTwips = wrapped ? toTwips(styleWidth(style, blank ? prefix : spacesCovering(style, prefix))) : 0;
+ long firstTwips = first ? toTwips(styleWidth(style, prefix)) : 0;
+ if (wrappedTwips <= 0 && firstTwips <= 0) {
return;
}
- boolean first = strategy == DocumentTextIndent.FIRST_LINE || strategy == DocumentTextIndent.ALL_LINES;
- boolean wrapped = strategy == DocumentTextIndent.FROM_SECOND_LINE || strategy == DocumentTextIndent.ALL_LINES;
- long wrappedTwips = wrapped ? toTwips(width) : 0;
- long firstTwips = first ? toTwips(width) : 0;
CTPPr properties = para.getCTP().isSetPPr() ? para.getCTP().getPPr() : para.getCTP().addNewPPr();
CTInd indent = properties.isSetInd() ? properties.getInd() : properties.addNewInd();
if (wrappedTwips > 0) {
@@ -5285,6 +5288,21 @@ private void indentAsThePrefixDoes(XWPFParagraph para, ParagraphNode node) {
}
}
+ /**
+ * The spaces the page sets a wrapped line after for a prefix with letters in it: as many as
+ * cover the prefix and the space that ends it, as the layout counts them
+ * ({@code ParagraphWrapping.computeIndentFromPrefix}); empty for a face the measured fonts
+ * do not hold.
+ */
+ private String spacesCovering(DocumentTextStyle style, String prefix) {
+ String ended = Character.isWhitespace(prefix.charAt(prefix.length() - 1)) ? prefix : prefix + " ";
+ double space = styleWidth(style, " ");
+ if (!(space > 1e-6)) {
+ return "";
+ }
+ return " ".repeat((int) Math.ceil(styleWidth(style, ended) / space));
+ }
+
/**
* The width the layout gives text set in a style, in points; 0 for no style, and for a
* face the measured fonts do not hold, as {@link #styleLineHeight} answers.
@@ -5536,8 +5554,9 @@ private void applyVerticalSpacing(XWPFParagraph target, DocumentNode source) {
* right — the text short of the edge the page runs it to, by exactly that padding, in
* both editors. {@code w:start} and {@code w:end} are no way out: both editors read them
* as {@code left} and {@code right}, measured. The sides are turned once, as the mark is
- * added, so every indent a paragraph carries has to be on it by then — the inset is,
- * since a body paragraph is created with it.
+ * added, so every indent written by page side has to be on it by then — the inset is,
+ * since a body paragraph is created with it. An indent measured from the flow's start, as
+ * a blank prefix's is ({@link #indentAsThePrefixDoes}), goes on after the turn.
*
* @param para the Word paragraph
* @param rightToLeft whether the page laid the paragraph out right to left
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxGeneratedIndentTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxGeneratedIndentTest.java
index 1bb532775..4c9936085 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxGeneratedIndentTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxGeneratedIndentTest.java
@@ -107,12 +107,42 @@ void aPrefixWithNoStrategyIsLeftAsItWas() throws Exception {
}
@Test
- void aPrefixWithLettersInItIsLeftAsItWas() throws Exception {
+ void wrappedLinesStartAfterTheSpacesThatCoverAPrefixWithLetters() throws Exception {
+ // The page draws "-" only on a first line, and sets a wrapped one after as many spaces
+ // as cover "- ": 0.333 + 0.278 em is 2.2 spaces, so three — 167 twips again.
+ CTInd indent = indentOf(p -> p.bulletOffset("-").indentStrategy(DocumentTextIndent.FROM_SECOND_LINE));
+
+ assertThat(DocxTwips.of(indent.getLeft())).isEqualTo(THREE_SPACES);
+ assertThat(DocxTwips.of(indent.getHanging())).isEqualTo(THREE_SPACES);
+ }
+
+ @Test
+ void aFirstLineIsNotMovedForLettersTheExportDoesNotWrite() throws Exception {
+ CTInd indent = indentOf(p -> p.bulletOffset("-").indentStrategy(DocumentTextIndent.ALL_LINES));
+
+ assertThat(DocxTwips.of(indent.getLeft())).as("the wrapped lines after the covering spaces")
+ .isEqualTo(THREE_SPACES);
+ assertThat(DocxTwips.of(indent.getHanging())).as("the first line back at the margin").isEqualTo(THREE_SPACES);
+
try (XWPFDocument document = DocxExports.withLayout(300, 400, 20, page -> page
- .addParagraph(p -> styled(p).bulletOffset("-").indentStrategy(DocumentTextIndent.ALL_LINES)))) {
- XWPFParagraph paragraph = paragraph(document);
+ .addParagraph(p -> styled(p).bulletOffset("-").indentStrategy(DocumentTextIndent.FIRST_LINE)))) {
+ assertThat(paragraph(document).getCTP().getPPr().isSetInd()).as("nothing to move").isFalse();
+ }
+ }
- assertThat(paragraph.getCTP().getPPr().isSetInd()).isFalse();
+ @Test
+ void aRightToLeftParagraphInAPaddedContainerKeepsBothItsIndents() throws Exception {
+ // The container's 20pt is on the page's left, the end of a right-to-left flow, and the
+ // prefix on the flow's start.
+ try (XWPFDocument document = DocxExports.withLayout(300, 400, 20, page -> page
+ .addSection(section -> section.padding(new DocumentInsets(0, 0, 0, 20))
+ .addParagraph(p -> styled(p).direction(TextDirection.RTL).bulletOffset(" ")
+ .indentStrategy(DocumentTextIndent.FROM_SECOND_LINE))))) {
+ CTInd indent = paragraph(document).getCTP().getPPr().getInd();
+
+ assertThat(DocxTwips.of(indent.getRight())).isEqualTo(20 * 20L);
+ assertThat(DocxTwips.of(indent.getLeft())).isEqualTo(THREE_SPACES);
+ assertThat(DocxTwips.of(indent.getHanging())).isEqualTo(THREE_SPACES);
}
}