From affb7ef1c96c62dda14ea290db25af9be76881de Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Fri, 2 Oct 2026 16:08:37 +0100
Subject: [PATCH 1/3] fix(docx): keep a bulletOffset paragraph's wrapped lines
the page's distance apart
---
CHANGELOG.md | 9 ++
docs/recipes/docx-export.md | 2 +-
.../libreoffice-windows-lines.tsv | 44 +++---
.../docx-fidelity/libreoffice-windows.tsv | 4 +-
.../docx-fidelity/word-windows-lines.tsv | 42 +++---
.../resources/docx-fidelity/word-windows.tsv | 4 +-
.../semantic/docx/DocxLayoutMetrics.java | 100 ++++++++++++-
.../semantic/docx/DocxSemanticBackend.java | 36 +++++
.../semantic/docx/DocxLinePitchTest.java | 139 ++++++++++++++++++
9 files changed, 330 insertions(+), 50 deletions(-)
create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePitchTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f7643d9b0..2766db5f6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,15 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A `bulletOffset` paragraph's wrapped lines keep the page's distance in DOCX.**
+ Word sets every line of a paragraph one height apart, and the export wrote the tallest. In
+ `EditorialProposal`'s bullets the wrapped line carries a prefix of spaces in the paragraph's
+ taller style, so the page sets it 13.05pt under the first and Word set it 13.8pt under, and
+ each item stood half a point to a point lower than the one above it. The prefix, written as
+ an indent, no longer counts towards the line; where the page's mean distance between the
+ lines is still more than the tallest, it is written instead, and what the paragraph then
+ falls short of the page is owed below it. In Word, `EditorialProposal`'s lines more than 2pt off fall from 21 to
+ 17, and `NorthlineProposal`'s from 6 to none.
- **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
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 83152fa9c..2bfc2389f 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -176,7 +176,7 @@ it cannot work out for itself:
| What | Where it lands |
|---|---|
-| Line height | `w:spacing w:lineRule="exact"` on every paragraph, cells and list items included — the height the engine measured, not a multiple Word would measure again against a substituted font. A paragraph the layout did not measure — one in a composed table cell — is left to the editor, and a line holding a picture above its text is written "at least" that height — except a paragraph of one line of text with room above for its pictures' reach, held exact at the page's height (see "Inline pictures"); in both the paragraph mark is set in the text's size and face, since the mark counts towards the last line's height. A line holding pictures and no text, where they fill the page's line, has its mark and its pictures' runs set at a point instead, so Word makes the line as tall as the pictures and no taller: in the paragraph's own size the mark's depth went under the picture (measured: `SlateOrange`'s skills, a 12.4pt icon beside each label, stood 0.2pt taller each in Word). A smaller picture in a line the page sets at its font's height keeps its mark, and so does a line holding a letter, a break, a tab, a field or a link's text. Both editors stand the baseline of an exact line four fifths of the way down it whatever the face (measured in Word and LibreOffice), and the page sets it the face's ascent below the line's top: where the two are half a point or more apart — a face with a deep descent, as Spectral's is — a paragraph's text is raised or lowered to the page's baseline by `w:position`, matched at its middle line; a list item's and a table text cell's are not yet. A picture among such text moves with it in Word; LibreOffice keeps a picture on its own baseline, where it stood before. Lines a container stacks tighter than their face — a title's lines a pitch apart — each end halfway between their letters and the next line's, since Word draws an exact line's text on screen only inside the line, and the last layer of a shape container, where its line runs past the foot, ends at the foot or below its letters |
+| Line height | `w:spacing w:lineRule="exact"` on every paragraph, cells and list items included — the height the engine measured, not a multiple Word would measure again against a substituted font. Word has one height for a paragraph's lines: it is the tallest line's text, or, where the page sets lines of different heights further apart than that, the page's mean distance between their baselines, with what the paragraph then falls short of the page owed below it. A blank `bulletOffset` prefix, written as an indent, does not count towards a line's text. A paragraph with no room above its first line for the gap between lines (one opening a cell or a column) shares the gaps out over all its lines, so its lines stand a little closer than on the page. A paragraph the layout did not measure — one in a composed table cell — is left to the editor, and a line holding a picture above its text is written "at least" that height — except a paragraph of one line of text with room above for its pictures' reach, held exact at the page's height (see "Inline pictures"); in both the paragraph mark is set in the text's size and face, since the mark counts towards the last line's height. A line holding pictures and no text, where they fill the page's line, has its mark and its pictures' runs set at a point instead, so Word makes the line as tall as the pictures and no taller: in the paragraph's own size the mark's depth went under the picture (measured: `SlateOrange`'s skills, a 12.4pt icon beside each label, stood 0.2pt taller each in Word). A smaller picture in a line the page sets at its font's height keeps its mark, and so does a line holding a letter, a break, a tab, a field or a link's text. Both editors stand the baseline of an exact line four fifths of the way down it whatever the face (measured in Word and LibreOffice), and the page sets it the face's ascent below the line's top: where the two are half a point or more apart — a face with a deep descent, as Spectral's is — a paragraph's text is raised or lowered to the page's baseline by `w:position`, matched at its middle line; a list item's and a table text cell's are not yet. A picture among such text moves with it in Word; LibreOffice keeps a picture on its own baseline, where it stood before. Lines a container stacks tighter than their face — a title's lines a pitch apart — each end halfway between their letters and the next line's, since Word draws an exact line's text on screen only inside the line, and the last layer of a shape container, where its line runs past the foot, ends at the foot or below its letters |
| Table columns | the resolved cell widths as `w:gridCol`, with `w:tblLayout` fixed so Word does not re-fit them |
| Row columns | where the layout placed each child, with the row's gap and padding folded into the neighbouring column and taken back out as that cell's margin. A column sized to its content (`DocumentRowColumn.auto()`) gets a point more, taken from the row's weight columns so the row keeps its width, for the reason a table's does: the editor's substitute font would wrap it — a table of contents' labels broke mid-word ("Intr" / "o") in LibreOffice without it. A row with no auto column, no weight column, or no stated columns (weights, an even split) is written as placed |
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 4bee140f2..47c264117 100644
--- a/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv
+++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv
@@ -3643,11 +3643,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: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:a579d6d1c12e -44.88 responsivedesignfordesktop,table
+proposal-editorial 1:d524c1a0811d -45.28 mobile
+proposal-editorial 1:8b712e7e93bf -45.40 contentstrategy&pagetemplates
+proposal-editorial 1:e9bf06d9b130 -45.40 developer-readyassets&specificat
+proposal-editorial 1:e93e7ba95ceb -45.40 launchchecklist&trainingsession
proposal-editorial 1:94d192b3a326 -46.32 timeline
proposal-editorial 1:02195b8e9896 -45.06 phase
proposal-editorial 1:c51faa148557 -45.06 focus
@@ -3695,15 +3695,15 @@ proposal-editorial 1:7ffd918ebc4a -43.46 £1,750
proposal-editorial 1:58656319146d -43.29 totalinvestment
proposal-editorial 1:4aadcd9bc785 -43.29 £19,250
proposal-editorial 1:51d2361f4fae -45.18 terms
-proposal-editorial 1:99ffab766326 -44.80 50%depositisrequiredtosecurethe
-proposal-editorial 1:5414e5671cbd -44.05 projectandschedulethekick-off.
-proposal-editorial 1:832db31f5c9a -43.87 includestworoundsofrevisionsper
-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:99ffab766326 -44.20 50%depositisrequiredtosecurethe
+proposal-editorial 1:5414e5671cbd -44.20 projectandschedulethekick-off.
+proposal-editorial 1:832db31f5c9a -44.22 includestworoundsofrevisionsper
+proposal-editorial 1:5130aeada3db -44.22 majordeliverable.
+proposal-editorial 1:ffce6ed7e6e5 -44.24 finalfilesandhandoffdeliveredupo
+proposal-editorial 1:566f68df6dd8 -44.24 finalpayment.
+proposal-editorial 1:06ebb5670fa8 -44.56 ownershipofallfinaldeliverables
+proposal-editorial 1:deda6d86d965 -44.21 transferstoasterahealthco.after
+proposal-editorial 1:aaa7d3e9affb -44.63 fullpaymentisreceived.
proposal-editorial 1:c1514c6cdad1 -44.29 acceptance
proposal-editorial 1:893663b06769 -43.26 bysigningbelow,youagreetothescop
proposal-editorial 1:df16232de5b9 -43.27 acceptedby:
@@ -3942,14 +3942,14 @@ proposal-northline 1:58656319146d 1.79 totalinvestment
proposal-northline 1:4aadcd9bc785 1.79 £19,250
proposal-northline 1:51d2361f4fae 0.61 terms
proposal-northline 1:99ffab766326 0.78 50%depositisrequiredtosecurethe
-proposal-northline 1:5414e5671cbd 1.55 projectandschedulethekick-off.
-proposal-northline 1:832db31f5c9a 1.73 includestworoundsofrevisionsper
-proposal-northline 1:5130aeada3db 2.50 majordeliverable.
-proposal-northline 1:ffce6ed7e6e5 2.68 finalfilesandhandoffdeliveredupo
-proposal-northline 1:566f68df6dd8 3.45 finalpayment.
-proposal-northline 1:06ebb5670fa8 3.03 ownershipofallfinaldeliverables
-proposal-northline 1:5312201597c1 3.80 transferstoasterahealthco.afterf
-proposal-northline 1:d31e8d77a816 3.80 paymentisreceived.
+proposal-northline 1:5414e5671cbd 0.80 projectandschedulethekick-off.
+proposal-northline 1:832db31f5c9a 0.78 includestworoundsofrevisionsper
+proposal-northline 1:5130aeada3db 0.80 majordeliverable.
+proposal-northline 1:ffce6ed7e6e5 0.78 finalfilesandhandoffdeliveredupo
+proposal-northline 1:566f68df6dd8 0.80 finalpayment.
+proposal-northline 1:06ebb5670fa8 1.08 ownershipofallfinaldeliverables
+proposal-northline 1:5312201597c1 1.45 transferstoasterahealthco.afterf
+proposal-northline 1:d31e8d77a816 1.05 paymentisreceived.
proposal-northline 1:c1514c6cdad1 1.41 acceptance
proposal-northline 1:893663b06769 1.73 bysigningbelow,youagreetothescop
proposal-northline 1:df16232de5b9 1.72 acceptedby:
diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv
index 40a15b85c..c7871b367 100644
--- a/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv
+++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv
@@ -56,9 +56,9 @@ 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 129 43.12 45.15 72
+proposal-editorial 2 2 132 129 43.29 45.18 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
+proposal-northline 2 2 132 132 0.38 1.47 0
receipt-modern 1 1 57 57 0.26 0.29 0
rota-cobalt 1 1 154 151 0.60 1.29 0
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 eaf270ddb..6e4d867e4 100644
--- a/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv
+++ b/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv
@@ -3744,11 +3744,11 @@ proposal-editorial 1:aaa2a6073d69 0.10 refinedlogosuite&brandguidelines
proposal-editorial 1:d626f7532564 0.06 color,typography&designsystem
proposal-editorial 1:018ec557a513 0.14 websiteuxwireframes
proposal-editorial 1:5ed4970fa285 0.10 high-fidelitywebsitedesigns
-proposal-editorial 1:a579d6d1c12e 0.38 responsivedesignfordesktop,table
-proposal-editorial 1:d524c1a0811d 0.65 mobile
-proposal-editorial 1:8b712e7e93bf 1.04 contentstrategy&pagetemplates
-proposal-editorial 1:e9bf06d9b130 1.12 developer-readyassets&specificat
-proposal-editorial 1:e93e7ba95ceb 1.08 launchchecklist&trainingsession
+proposal-editorial 1:a579d6d1c12e 0.62 responsivedesignfordesktop,table
+proposal-editorial 1:d524c1a0811d 0.17 mobile
+proposal-editorial 1:8b712e7e93bf 0.08 contentstrategy&pagetemplates
+proposal-editorial 1:e9bf06d9b130 0.04 developer-readyassets&specificat
+proposal-editorial 1:e93e7ba95ceb 0.12 launchchecklist&trainingsession
proposal-editorial 1:94d192b3a326 0.47 timeline
proposal-editorial 1:02195b8e9896 0.50 phase
proposal-editorial 1:c51faa148557 0.50 focus
@@ -3796,13 +3796,13 @@ proposal-editorial 1:7ffd918ebc4a 2.20 £1,750
proposal-editorial 1:58656319146d 2.36 totalinvestment
proposal-editorial 1:4aadcd9bc785 2.36 £19,250
proposal-editorial 1:51d2361f4fae 1.66 terms
-proposal-editorial 1:99ffab766326 1.06 50%depositisrequiredtosecurethe
-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:99ffab766326 1.42 50%depositisrequiredtosecurethe
+proposal-editorial 1:5414e5671cbd 1.33 projectandschedulethekick-off.
+proposal-editorial 1:832db31f5c9a 1.36 includestworoundsofrevisionsper
+proposal-editorial 1:5130aeada3db 1.39 majordeliverable.
+proposal-editorial 1:06ebb5670fa8 1.12 ownershipofallfinaldeliverables
+proposal-editorial 1:deda6d86d965 1.51 transferstoasterahealthco.after
+proposal-editorial 1:aaa7d3e9affb 1.04 fullpaymentisreceived.
proposal-editorial 1:c1514c6cdad1 2.62 acceptance
proposal-editorial 1:893663b06769 2.26 bysigningbelow,youagreetothescop
proposal-editorial 1:df16232de5b9 2.41 acceptedby:
@@ -4040,15 +4040,15 @@ proposal-northline 1:7ffd918ebc4a 1.75 £1,750
proposal-northline 1:58656319146d 1.87 totalinvestment
proposal-northline 1:4aadcd9bc785 1.87 £19,250
proposal-northline 1:51d2361f4fae 0.82 terms
-proposal-northline 1:99ffab766326 0.97 50%depositisrequiredtosecurethe
-proposal-northline 1:5414e5671cbd 1.74 projectandschedulethekick-off.
-proposal-northline 1:832db31f5c9a 1.97 includestworoundsofrevisionsper
-proposal-northline 1:5130aeada3db 2.74 majordeliverable.
-proposal-northline 1:ffce6ed7e6e5 2.85 finalfilesandhandoffdeliveredupo
-proposal-northline 1:566f68df6dd8 3.62 finalpayment.
-proposal-northline 1:06ebb5670fa8 3.37 ownershipofallfinaldeliverables
-proposal-northline 1:5312201597c1 4.14 transferstoasterahealthco.afterf
-proposal-northline 1:d31e8d77a816 4.17 paymentisreceived.
+proposal-northline 1:99ffab766326 0.85 50%depositisrequiredtosecurethe
+proposal-northline 1:5414e5671cbd 0.90 projectandschedulethekick-off.
+proposal-northline 1:832db31f5c9a 0.89 includestworoundsofrevisionsper
+proposal-northline 1:5130aeada3db 0.82 majordeliverable.
+proposal-northline 1:ffce6ed7e6e5 0.81 finalfilesandhandoffdeliveredupo
+proposal-northline 1:566f68df6dd8 0.86 finalpayment.
+proposal-northline 1:06ebb5670fa8 1.21 ownershipofallfinaldeliverables
+proposal-northline 1:5312201597c1 1.50 transferstoasterahealthco.afterf
+proposal-northline 1:d31e8d77a816 1.02 paymentisreceived.
proposal-northline 1:c1514c6cdad1 1.69 acceptance
proposal-northline 1:893663b06769 1.96 bysigningbelow,youagreetothescop
proposal-northline 1:df16232de5b9 1.82 acceptedby:
diff --git a/qa/src/test/resources/docx-fidelity/word-windows.tsv b/qa/src/test/resources/docx-fidelity/word-windows.tsv
index f8de405e9..b11a51424 100644
--- a/qa/src/test/resources/docx-fidelity/word-windows.tsv
+++ b/qa/src/test/resources/docx-fidelity/word-windows.tsv
@@ -56,9 +56,9 @@ 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 130 0.50 2.16 21
+proposal-editorial 2 2 132 130 0.47 2.07 17
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
+proposal-northline 2 2 132 132 0.36 1.60 0
receipt-modern 1 1 57 57 0.33 0.44 0
rota-cobalt 1 1 154 154 0.74 1.42 0
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 ff03c2c20..6a352fb50 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
@@ -502,7 +502,13 @@ String pathOf(DocumentNode node) {
* where the style's height clipped them. An inline picture does not count; the lines
* that hold one are made room for separately ({@code makeRoomForPictures}). A line with
* no text at all — an empty line, one holding only a picture — has the style's height,
- * and counts with it.
+ * and counts with it. A paragraph's blank {@code bulletOffset}, which the export writes as
+ * an indent, does not count towards its line ({@link #writtenTextHeight}).
+ *
+ * Where the page sets the lines further apart than the tallest of them and the gap —
+ * lines of different heights, each set its own height — the page's mean distance between
+ * their baselines, less the gap, is the height instead: Word sets every line that far apart,
+ * and the first and last lines' baselines stand that far apart on both.
*
* Read from every fragment the node emitted: a paragraph split across a page boundary
* emits one fragment per page, and its tallest line may be on any of them.
@@ -511,12 +517,44 @@ String pathOf(DocumentNode node) {
* @return the line height in points, or empty when the node laid out nothing
*/
OptionalDouble lineHeight(DocumentNode node) {
+ return lineHeight(node, true, true);
+ }
+
+ /**
+ * Whether a node's line height is other than its tallest laid-out line ({@link #lineHeight}):
+ * the page's distance between its lines, or a line without its prefix. The paragraph, set at
+ * that one height, can then come out shorter than the page's; at the tallest line it cannot.
+ *
+ * @param node any node that lays its text out as paragraph lines
+ * @return whether the height is not the tallest line's
+ */
+ boolean lineIsNotTheTallest(DocumentNode node) {
+ OptionalDouble written = lineHeight(node, true, true);
+ OptionalDouble tallest = lineHeight(node, false, false);
+ return written.isPresent() && tallest.isPresent()
+ && Math.abs(written.getAsDouble() - tallest.getAsDouble()) > 0.01;
+ }
+
+ private OptionalDouble lineHeight(DocumentNode node, boolean byPitch, boolean withoutPrefix) {
double text = 0;
double style = 0;
+ double pitches = 0;
+ int pairs = 0;
+ boolean prefixed = withoutPrefix && node instanceof ParagraphNode paragraphNode
+ && !paragraphNode.bulletOffset().isEmpty()
+ && paragraphNode.indentStrategy() != com.demcha.compose.document.style.DocumentTextIndent.NONE;
for (PlacedFragment fragment : textFragmentsOf(node)) {
if (fragment.payload() instanceof ParagraphFragmentPayload paragraph) {
+ ParagraphLine above = null;
for (ParagraphLine line : paragraph.lines()) {
- text = Math.max(text, line.textLineHeight());
+ text = Math.max(text, prefixed ? writtenTextHeight(line) : line.textLineHeight());
+ // A line a picture makes taller is made room for on its own (makeRoomForPictures).
+ if (above != null && setByText(above) && setByText(line)) {
+ pitches += above.baselineOffsetFromBottom() + Math.max(0, paragraph.lineGap())
+ + line.lineHeight() - line.baselineOffsetFromBottom();
+ pairs++;
+ }
+ above = line;
}
if (style <= 0) {
style = paragraph.lineHeight();
@@ -524,11 +562,69 @@ OptionalDouble lineHeight(DocumentNode node) {
}
}
if (text > 0) {
+ // Word sets every line of a paragraph one height apart; the page sets each pair of
+ // lines its own distance apart. Where those differ, the page's mean distance — less
+ // the gap Word is given on top (applyLineGap) — keeps the first and last lines where
+ // the page has them, never below the tallest written line, which would clip it.
+ if (byPitch && pairs > 0) {
+ text = Math.max(text, pitches / pairs - Math.max(0, lineGap(node)));
+ }
return OptionalDouble.of(text);
}
return style > 0 ? OptionalDouble.of(style) : OptionalDouble.empty();
}
+ /**
+ * How tall the page sets a node's lines together: each line's own height and the gap
+ * between each two. Empty when the lines are not on one fragment, or a picture makes one
+ * of them taller than its text — those are made room for on their own.
+ *
+ * @param node any node that lays its text out as paragraph lines
+ * @return the height in points, or empty
+ */
+ OptionalDouble linesHeight(DocumentNode node) {
+ List fragments = textFragmentsOf(node);
+ if (fragments.size() != 1 || !(fragments.get(0).payload() instanceof ParagraphFragmentPayload paragraph)
+ || paragraph.lines().isEmpty()) {
+ return OptionalDouble.empty();
+ }
+ double height = Math.max(0, paragraph.lineGap()) * (paragraph.lines().size() - 1);
+ for (ParagraphLine line : paragraph.lines()) {
+ if (!setByText(line)) {
+ return OptionalDouble.empty();
+ }
+ height += line.lineHeight();
+ }
+ return OptionalDouble.of(height);
+ }
+
+ /** Whether a line is as tall as its text, nothing inline making it taller. */
+ private static boolean setByText(ParagraphLine line) {
+ return Math.abs(line.lineHeight() - line.textLineHeight()) < 0.01;
+ }
+
+ /**
+ * The height of a line's text the export writes: the line's own, less a leading span of
+ * blank prefix the page sets in the paragraph's style ({@code bulletOffset}), which the
+ * export writes as an indent and not as text. The page measures that span with the rest,
+ * so a prefix in a face taller than the runs made a wrapped line taller than its text.
+ */
+ private static double writtenTextHeight(ParagraphLine line) {
+ List spans = line.spans();
+ if (spans.size() < 2
+ || !(spans.get(0) instanceof com.demcha.compose.document.layout.payloads.ParagraphTextSpan prefix)
+ || !prefix.text().isBlank()) {
+ return line.textLineHeight();
+ }
+ double height = 0;
+ for (int i = 1; i < spans.size(); i++) {
+ if (spans.get(i) instanceof com.demcha.compose.document.layout.payloads.ParagraphTextSpan span) {
+ height = Math.max(height, span.height());
+ }
+ }
+ return height > 0 ? Math.min(height, line.textLineHeight()) : line.textLineHeight();
+ }
+
/**
* The first line a node laid out as paragraph lines: its height, its text's ascent and
* how far its baseline sits above its bottom.
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 af8a039be..fc6c1a597 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
@@ -5376,9 +5376,45 @@ private boolean applyParagraphProperties(XWPFParagraph target, ParagraphNode sou
applyLineHeight(target, stacked != null ? java.util.OptionalDouble.of(stacked.height()) : layout.lineHeight(source));
applyVerticalSpacing(target, source);
applyLineGap(target, layout.lineGap(source), layout.lineCount(source));
+ if (stacked == null) {
+ oweWhatTheLinesFallShort(target, source);
+ }
return rightToLeft;
}
+ /**
+ * Owes below a paragraph what its lines are shorter in Word than on the page.
+ *
+ * Word sets a paragraph's lines one height apart, and the page sets each line its own
+ * height. Where they differ the written height is the page's distance between lines
+ * ({@code DocxLayoutMetrics.lineHeight}), and the paragraph comes out short by what its
+ * first line's top and its last line's foot hold beyond that: {@code EditorialProposal}'s
+ * terms, whose wrapped lines carry a taller prefix than their first, stood 0.6pt higher
+ * with every item. Only a paragraph written at a height other than its tallest laid-out
+ * line is made up to the page; one written at its tallest line, to keep its letters whole,
+ * comes out no shorter than the page and keeps that height.
+ */
+ private void oweWhatTheLinesFallShort(XWPFParagraph target, ParagraphNode source) {
+ java.util.OptionalDouble page = layout.linesHeight(source);
+ CTPPr properties = target.getCTP().getPPr();
+ if (page.isEmpty() || properties == null || !hasAnExactLine(target) || !layout.lineIsNotTheTallest(source)) {
+ return;
+ }
+ Long line = writtenTwips(properties.getSpacing().getLine());
+ if (line == null) {
+ return;
+ }
+ double written = layout.lineCount(source) * (line / POINT_TO_TWIP)
+ - lineTopsTakenIn.getOrDefault(target.getCTP(), 0.0);
+ double shortBy = page.getAsDouble() - written;
+ if (shortBy > LINES_SHORT_TOLERANCE) {
+ owePendingSpacingAfter(shortBy);
+ }
+ }
+
+ /** What a paragraph's lines may fall short of the page by, twips rounding, before it is owed. */
+ private static final double LINES_SHORT_TOLERANCE = 0.1;
+
/**
* Sets a paragraph's mark in its text's size and face, where the editor may grow the line.
*
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePitchTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePitchTest.java
new file mode 100644
index 000000000..72a6b5391
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePitchTest.java
@@ -0,0 +1,139 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.dsl.ParagraphBuilder;
+import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload;
+import com.demcha.compose.document.layout.payloads.ParagraphLine;
+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.CTSpacing;
+
+import java.io.ByteArrayInputStream;
+import java.util.List;
+import java.util.function.Consumer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.offset;
+
+/**
+ * A paragraph whose lines the page sets at different heights keeps the page's distance between
+ * them in Word.
+ *
+ * Word sets every line of a paragraph one height apart. {@code EditorialProposal}'s terms are
+ * bullets whose wrapped lines start with a prefix of spaces set in the paragraph's style, taller
+ * than the runs: on the page the first line is 12pt and the wrapped one 12.95, their baselines
+ * 13.05pt apart. Written at the tallest line, Word set them 13.8pt apart, and every item stood
+ * lower than the one above it.
+ *
+ * @author Artem Demchyshyn
+ */
+class DocxLinePitchTest {
+
+ private static final DocumentTextStyle BODY = DocumentTextStyle.builder().fontName(FontName.LATO).size(10).build();
+ private static final String TEXT = " 50% deposit is required to secure the project and schedule the kick-off.";
+
+ @Test
+ void linesOfDifferentHeightsAreWrittenTheirDistanceApartOnThePage() throws Exception {
+ Exported exported = export(p -> p.inlineText(TEXT, BODY)
+ .bulletOffset(" ").indentStrategy(DocumentTextIndent.FROM_SECOND_LINE).lineSpacing(0.87));
+ List lines = exported.lines();
+ assertThat(lines).hasSize(2);
+ assertThat(lines.get(1).lineHeight()).as("the prefix makes the wrapped line taller")
+ .isGreaterThan(lines.get(0).lineHeight() + 0.5);
+
+ double pitch = lines.get(0).baselineOffsetFromBottom() + exported.gap()
+ + lines.get(1).lineHeight() - lines.get(1).baselineOffsetFromBottom();
+ assertThat(exported.lineTwips())
+ .as("the page's distance between the baselines, not the tallest line and the gap;"
+ + " the line and the gap are rounded apart")
+ .isCloseTo(Math.round(pitch * 20), org.assertj.core.api.Assertions.within(1L));
+ }
+
+ @Test
+ void whatTheLinesFallShortOfThePageIsOwedBelowThem() throws Exception {
+ Exported exported = export(p -> p.inlineText(TEXT, BODY)
+ .bulletOffset(" ").indentStrategy(DocumentTextIndent.FROM_SECOND_LINE).lineSpacing(0.87));
+ List lines = exported.lines();
+ double page = lines.get(0).lineHeight() + exported.gap() + lines.get(1).lineHeight();
+
+ // What is owed below is written as the space above the next paragraph, which has none.
+ double word = 2 * exported.lineTwips() / 20.0 - exported.gapTakenFromAbove()
+ + exported.beforeNextTwips() / 20.0;
+ assertThat(word).as("the paragraph and what it owes as tall in Word as on the page")
+ .isCloseTo(page, offset(0.06));
+ assertThat(exported.beforeNextTwips()).isPositive();
+ }
+
+ @Test
+ void linesOfOneHeightAreWrittenAsBefore() throws Exception {
+ // The tallest line and the gap, the gap taken back out above: nothing owed.
+ Exported exported = export(p -> p
+ .text("A line long enough to wrap onto a second one and then onto a third in this measure")
+ .textStyle(BODY).lineSpacing(2));
+ List lines = exported.lines();
+ assertThat(lines).hasSizeGreaterThan(1);
+ int n = lines.size();
+
+ assertThat(exported.lineTwips())
+ .isEqualTo(Math.round(lines.get(0).textLineHeight() * 20) + Math.round(((n - 1) * 40.0 + 40) / n));
+ assertThat(exported.beforeNextTwips()).isZero();
+ }
+
+ private record Exported(XWPFParagraph paragraph, XWPFParagraph next, List lines, double gap) {
+
+ CTSpacing spacing() {
+ return paragraph.getCTP().getPPr().getSpacing();
+ }
+
+ long lineTwips() {
+ return DocxTwips.of(spacing().getLine());
+ }
+
+ /** The 12pt above the paragraph, less what is written there. */
+ double gapTakenFromAbove() {
+ long before = spacing().isSetBefore() ? DocxTwips.of(spacing().getBefore()) : 0;
+ return (240 - before) / 20.0;
+ }
+
+ long beforeNextTwips() {
+ CTSpacing spacing = next.getCTP().getPPr() == null ? null : next.getCTP().getPPr().getSpacing();
+ return spacing == null || !spacing.isSetBefore() ? 0 : DocxTwips.of(spacing.getBefore());
+ }
+ }
+
+ private static Exported export(Consumer content) throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(260, 400)
+ .margin(DocumentInsets.of(20))
+ .create()) {
+ session.pageFlow(page -> page
+ .addParagraph(p -> p.text("Above").textStyle(BODY))
+ .addParagraph(p -> content.accept(p.margin(DocumentInsets.top(12))))
+ .addParagraph(p -> p.text("Below").textStyle(BODY)));
+ ParagraphFragmentPayload payload = session.layoutGraph().fragments().stream()
+ .map(fragment -> fragment.payload())
+ .filter(ParagraphFragmentPayload.class::isInstance)
+ .map(ParagraphFragmentPayload.class::cast)
+ .filter(paragraph -> paragraph.lines().size() > 1)
+ .findFirst()
+ .orElseThrow();
+ byte[] docx = session.export(new DocxSemanticBackend());
+ XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(docx));
+ XWPFParagraph paragraph = document.getParagraphs().stream()
+ .filter(p -> p.getText().contains("deposit") || p.getText().startsWith("A line"))
+ .findFirst()
+ .orElseThrow();
+ XWPFParagraph next = document.getParagraphs().stream()
+ .filter(p -> p.getText().equals("Below"))
+ .findFirst()
+ .orElseThrow();
+ return new Exported(paragraph, next, payload.lines(), Math.max(0, payload.lineGap()));
+ }
+ }
+}
From 0eb850b260907eea6989408f56c0392ba52e5de6 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Fri, 2 Oct 2026 16:29:07 +0100
Subject: [PATCH 2/3] fix(docx): keep a one-line paragraph's laid-out height,
and take the Linux LibreOffice baseline from CI
---
CHANGELOG.md | 4 +-
docs/recipes/docx-export.md | 2 +-
.../docx-fidelity/libreoffice-linux-lines.tsv | 44 +++++++++----------
.../docx-fidelity/libreoffice-linux.tsv | 4 +-
.../semantic/docx/DocxLayoutMetrics.java | 9 ++--
.../semantic/docx/DocxLinePitchTest.java | 24 ++++++++++
6 files changed, 57 insertions(+), 30 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2766db5f6..082ecdffd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,8 +15,8 @@ follow semantic versioning; release dates are ISO 8601.
each item stood half a point to a point lower than the one above it. The prefix, written as
an indent, no longer counts towards the line; where the page's mean distance between the
lines is still more than the tallest, it is written instead, and what the paragraph then
- falls short of the page is owed below it. In Word, `EditorialProposal`'s lines more than 2pt off fall from 21 to
- 17, and `NorthlineProposal`'s from 6 to none.
+ falls short of the page is owed below it. In Word, `EditorialProposal`'s lines more than
+ 2pt off fall from 21 to 17, and `NorthlineProposal`'s from 6 to none.
- **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
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 2bfc2389f..0bbf9c957 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -176,7 +176,7 @@ it cannot work out for itself:
| What | Where it lands |
|---|---|
-| Line height | `w:spacing w:lineRule="exact"` on every paragraph, cells and list items included — the height the engine measured, not a multiple Word would measure again against a substituted font. Word has one height for a paragraph's lines: it is the tallest line's text, or, where the page sets lines of different heights further apart than that, the page's mean distance between their baselines, with what the paragraph then falls short of the page owed below it. A blank `bulletOffset` prefix, written as an indent, does not count towards a line's text. A paragraph with no room above its first line for the gap between lines (one opening a cell or a column) shares the gaps out over all its lines, so its lines stand a little closer than on the page. A paragraph the layout did not measure — one in a composed table cell — is left to the editor, and a line holding a picture above its text is written "at least" that height — except a paragraph of one line of text with room above for its pictures' reach, held exact at the page's height (see "Inline pictures"); in both the paragraph mark is set in the text's size and face, since the mark counts towards the last line's height. A line holding pictures and no text, where they fill the page's line, has its mark and its pictures' runs set at a point instead, so Word makes the line as tall as the pictures and no taller: in the paragraph's own size the mark's depth went under the picture (measured: `SlateOrange`'s skills, a 12.4pt icon beside each label, stood 0.2pt taller each in Word). A smaller picture in a line the page sets at its font's height keeps its mark, and so does a line holding a letter, a break, a tab, a field or a link's text. Both editors stand the baseline of an exact line four fifths of the way down it whatever the face (measured in Word and LibreOffice), and the page sets it the face's ascent below the line's top: where the two are half a point or more apart — a face with a deep descent, as Spectral's is — a paragraph's text is raised or lowered to the page's baseline by `w:position`, matched at its middle line; a list item's and a table text cell's are not yet. A picture among such text moves with it in Word; LibreOffice keeps a picture on its own baseline, where it stood before. Lines a container stacks tighter than their face — a title's lines a pitch apart — each end halfway between their letters and the next line's, since Word draws an exact line's text on screen only inside the line, and the last layer of a shape container, where its line runs past the foot, ends at the foot or below its letters |
+| Line height | `w:spacing w:lineRule="exact"` on every paragraph, cells and list items included — the height the engine measured, not a multiple Word would measure again against a substituted font. Word has one height for a paragraph's lines: it is the tallest line's text, or, where the page sets lines of different heights further apart than that, the page's mean distance between their baselines, with what the paragraph then falls short of the page owed below it. In a paragraph of more than one line, a blank `bulletOffset` prefix, written as an indent, does not count towards a line's text. A paragraph with no room above its first line for the gap between lines (one opening a cell or a column) shares the gaps out over all its lines, so its lines stand a little closer than on the page. A paragraph the layout did not measure — one in a composed table cell — is left to the editor, and a line holding a picture above its text is written "at least" that height — except a paragraph of one line of text with room above for its pictures' reach, held exact at the page's height (see "Inline pictures"); in both the paragraph mark is set in the text's size and face, since the mark counts towards the last line's height. A line holding pictures and no text, where they fill the page's line, has its mark and its pictures' runs set at a point instead, so Word makes the line as tall as the pictures and no taller: in the paragraph's own size the mark's depth went under the picture (measured: `SlateOrange`'s skills, a 12.4pt icon beside each label, stood 0.2pt taller each in Word). A smaller picture in a line the page sets at its font's height keeps its mark, and so does a line holding a letter, a break, a tab, a field or a link's text. Both editors stand the baseline of an exact line four fifths of the way down it whatever the face (measured in Word and LibreOffice), and the page sets it the face's ascent below the line's top: where the two are half a point or more apart — a face with a deep descent, as Spectral's is — a paragraph's text is raised or lowered to the page's baseline by `w:position`, matched at its middle line; a list item's and a table text cell's are not yet. A picture among such text moves with it in Word; LibreOffice keeps a picture on its own baseline, where it stood before. Lines a container stacks tighter than their face — a title's lines a pitch apart — each end halfway between their letters and the next line's, since Word draws an exact line's text on screen only inside the line, and the last layer of a shape container, where its line runs past the foot, ends at the foot or below its letters |
| Table columns | the resolved cell widths as `w:gridCol`, with `w:tblLayout` fixed so Word does not re-fit them |
| Row columns | where the layout placed each child, with the row's gap and padding folded into the neighbouring column and taken back out as that cell's margin. A column sized to its content (`DocumentRowColumn.auto()`) gets a point more, taken from the row's weight columns so the row keeps its width, for the reason a table's does: the editor's substitute font would wrap it — a table of contents' labels broke mid-word ("Intr" / "o") in LibreOffice without it. A row with no auto column, no weight column, or no stated columns (weights, an even split) is written as placed |
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 b3693ce8d..f91253028 100644
--- a/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv
+++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv
@@ -3742,11 +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: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:a579d6d1c12e -44.88 responsivedesignfordesktop,table
+proposal-editorial 1:d524c1a0811d -45.28 mobile
+proposal-editorial 1:8b712e7e93bf -45.40 contentstrategy&pagetemplates
+proposal-editorial 1:e9bf06d9b130 -45.40 developer-readyassets&specificat
+proposal-editorial 1:e93e7ba95ceb -45.40 launchchecklist&trainingsession
proposal-editorial 1:94d192b3a326 -46.32 timeline
proposal-editorial 1:02195b8e9896 -45.21 phase
proposal-editorial 1:c51faa148557 -45.21 focus
@@ -3794,15 +3794,15 @@ proposal-editorial 1:7ffd918ebc4a -44.36 £1,750
proposal-editorial 1:58656319146d -44.34 totalinvestment
proposal-editorial 1:4aadcd9bc785 -44.34 £19,250
proposal-editorial 1:51d2361f4fae -45.78 terms
-proposal-editorial 1:99ffab766326 -45.40 50%depositisrequiredtosecurethe
-proposal-editorial 1:5414e5671cbd -44.65 projectandschedulethekick-off.
-proposal-editorial 1:832db31f5c9a -44.47 includestworoundsofrevisionsper
-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:99ffab766326 -44.80 50%depositisrequiredtosecurethe
+proposal-editorial 1:5414e5671cbd -44.80 projectandschedulethekick-off.
+proposal-editorial 1:832db31f5c9a -44.82 includestworoundsofrevisionsper
+proposal-editorial 1:5130aeada3db -44.82 majordeliverable.
+proposal-editorial 1:ffce6ed7e6e5 -44.84 finalfilesandhandoffdeliveredupo
+proposal-editorial 1:566f68df6dd8 -44.84 finalpayment.
+proposal-editorial 1:06ebb5670fa8 -45.16 ownershipofallfinaldeliverables
+proposal-editorial 1:deda6d86d965 -44.81 transferstoasterahealthco.after
+proposal-editorial 1:aaa7d3e9affb -45.23 fullpaymentisreceived.
proposal-editorial 1:c1514c6cdad1 -45.49 acceptance
proposal-editorial 1:893663b06769 -44.46 bysigningbelow,youagreetothescop
proposal-editorial 1:df16232de5b9 -44.47 acceptedby:
@@ -4041,14 +4041,14 @@ proposal-northline 1:58656319146d 0.74 totalinvestment
proposal-northline 1:4aadcd9bc785 0.74 £19,250
proposal-northline 1:51d2361f4fae 0.01 terms
proposal-northline 1:99ffab766326 0.18 50%depositisrequiredtosecurethe
-proposal-northline 1:5414e5671cbd 0.95 projectandschedulethekick-off.
-proposal-northline 1:832db31f5c9a 1.13 includestworoundsofrevisionsper
-proposal-northline 1:5130aeada3db 1.90 majordeliverable.
-proposal-northline 1:ffce6ed7e6e5 2.08 finalfilesandhandoffdeliveredupo
-proposal-northline 1:566f68df6dd8 2.85 finalpayment.
-proposal-northline 1:06ebb5670fa8 2.43 ownershipofallfinaldeliverables
-proposal-northline 1:5312201597c1 3.20 transferstoasterahealthco.afterf
-proposal-northline 1:d31e8d77a816 3.20 paymentisreceived.
+proposal-northline 1:5414e5671cbd 0.20 projectandschedulethekick-off.
+proposal-northline 1:832db31f5c9a 0.18 includestworoundsofrevisionsper
+proposal-northline 1:5130aeada3db 0.20 majordeliverable.
+proposal-northline 1:ffce6ed7e6e5 0.18 finalfilesandhandoffdeliveredupo
+proposal-northline 1:566f68df6dd8 0.20 finalpayment.
+proposal-northline 1:06ebb5670fa8 0.48 ownershipofallfinaldeliverables
+proposal-northline 1:5312201597c1 0.85 transferstoasterahealthco.afterf
+proposal-northline 1:d31e8d77a816 0.45 paymentisreceived.
proposal-northline 1:c1514c6cdad1 0.21 acceptance
proposal-northline 1:893663b06769 0.53 bysigningbelow,youagreetothescop
proposal-northline 1:df16232de5b9 0.52 acceptedby:
diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv
index 64fe983b3..18a4e13b2 100644
--- a/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv
+++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv
@@ -56,9 +56,9 @@ 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 129 43.72 45.38 72
+proposal-editorial 2 2 132 129 43.95 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
+proposal-northline 2 2 132 132 0.27 0.72 0
receipt-modern 1 1 57 57 0.26 0.29 0
rota-cobalt 1 1 154 151 0.55 1.24 0
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 6a352fb50..f41759aad 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
@@ -502,8 +502,9 @@ String pathOf(DocumentNode node) {
* where the style's height clipped them. An inline picture does not count; the lines
* that hold one are made room for separately ({@code makeRoomForPictures}). A line with
* no text at all — an empty line, one holding only a picture — has the style's height,
- * and counts with it. A paragraph's blank {@code bulletOffset}, which the export writes as
- * an indent, does not count towards its line ({@link #writtenTextHeight}).
+ * and counts with it. In a paragraph of more than one line, a blank {@code bulletOffset},
+ * which the export writes as an indent, does not count towards its line
+ * ({@link #writtenTextHeight}).
*
* Where the page sets the lines further apart than the tallest of them and the gap —
* lines of different heights, each set its own height — the page's mean distance between
@@ -540,7 +541,9 @@ private OptionalDouble lineHeight(DocumentNode node, boolean byPitch, boolean wi
double style = 0;
double pitches = 0;
int pairs = 0;
- boolean prefixed = withoutPrefix && node instanceof ParagraphNode paragraphNode
+ // A line of its own keeps its laid-out height: a paragraph of one line is set at it where
+ // nothing would make up a shorter one (riseIntoItsLine, holdPicturesInTheLine).
+ boolean prefixed = withoutPrefix && lineCount(node) > 1 && node instanceof ParagraphNode paragraphNode
&& !paragraphNode.bulletOffset().isEmpty()
&& paragraphNode.indentStrategy() != com.demcha.compose.document.style.DocumentTextIndent.NONE;
for (PlacedFragment fragment : textFragmentsOf(node)) {
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePitchTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePitchTest.java
index 72a6b5391..b08bb4321 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePitchTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxLinePitchTest.java
@@ -85,6 +85,30 @@ void linesOfOneHeightAreWrittenAsBefore() throws Exception {
assertThat(exported.beforeNextTwips()).isZero();
}
+ @Test
+ void aParagraphOfOneLineKeepsItsLaidOutHeight() throws Exception {
+ // Nothing makes a shorter line up where a single line is set by its rise or its pictures,
+ // so its prefix still counts.
+ try (DocumentSession session = GraphCompose.document().pageSize(260, 400).margin(DocumentInsets.of(20)).create()) {
+ session.pageFlow(page -> page.addParagraph(p -> p.inlineText("Short", BODY)
+ .bulletOffset(" ").indentStrategy(DocumentTextIndent.FIRST_LINE)));
+ ParagraphLine line = session.layoutGraph().fragments().stream()
+ .map(fragment -> fragment.payload())
+ .filter(ParagraphFragmentPayload.class::isInstance)
+ .map(payload -> ((ParagraphFragmentPayload) payload).lines().get(0))
+ .findFirst()
+ .orElseThrow();
+ XWPFDocument document = new XWPFDocument(new ByteArrayInputStream(session.export(new DocxSemanticBackend())));
+ XWPFParagraph paragraph = document.getParagraphs().stream()
+ .filter(p -> p.getText().contains("Short"))
+ .findFirst()
+ .orElseThrow();
+
+ assertThat(DocxTwips.of(paragraph.getCTP().getPPr().getSpacing().getLine()))
+ .isEqualTo(Math.round(line.textLineHeight() * 20));
+ }
+ }
+
private record Exported(XWPFParagraph paragraph, XWPFParagraph next, List lines, double gap) {
CTSpacing spacing() {
From f68c4ee62fbae11567674a62987f73c7bb73c511 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Fri, 2 Oct 2026 16:44:55 +0100
Subject: [PATCH 3/3] fix(docx): write a line other than at its tallest only on
one page, below the lines' mean height, and discount the prefix only where it
is set
---
docs/recipes/docx-export.md | 2 +-
.../semantic/docx/DocxLayoutMetrics.java | 36 ++++++++++++++-----
2 files changed, 29 insertions(+), 9 deletions(-)
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 0bbf9c957..758d0ff43 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -176,7 +176,7 @@ it cannot work out for itself:
| What | Where it lands |
|---|---|
-| Line height | `w:spacing w:lineRule="exact"` on every paragraph, cells and list items included — the height the engine measured, not a multiple Word would measure again against a substituted font. Word has one height for a paragraph's lines: it is the tallest line's text, or, where the page sets lines of different heights further apart than that, the page's mean distance between their baselines, with what the paragraph then falls short of the page owed below it. In a paragraph of more than one line, a blank `bulletOffset` prefix, written as an indent, does not count towards a line's text. A paragraph with no room above its first line for the gap between lines (one opening a cell or a column) shares the gaps out over all its lines, so its lines stand a little closer than on the page. A paragraph the layout did not measure — one in a composed table cell — is left to the editor, and a line holding a picture above its text is written "at least" that height — except a paragraph of one line of text with room above for its pictures' reach, held exact at the page's height (see "Inline pictures"); in both the paragraph mark is set in the text's size and face, since the mark counts towards the last line's height. A line holding pictures and no text, where they fill the page's line, has its mark and its pictures' runs set at a point instead, so Word makes the line as tall as the pictures and no taller: in the paragraph's own size the mark's depth went under the picture (measured: `SlateOrange`'s skills, a 12.4pt icon beside each label, stood 0.2pt taller each in Word). A smaller picture in a line the page sets at its font's height keeps its mark, and so does a line holding a letter, a break, a tab, a field or a link's text. Both editors stand the baseline of an exact line four fifths of the way down it whatever the face (measured in Word and LibreOffice), and the page sets it the face's ascent below the line's top: where the two are half a point or more apart — a face with a deep descent, as Spectral's is — a paragraph's text is raised or lowered to the page's baseline by `w:position`, matched at its middle line; a list item's and a table text cell's are not yet. A picture among such text moves with it in Word; LibreOffice keeps a picture on its own baseline, where it stood before. Lines a container stacks tighter than their face — a title's lines a pitch apart — each end halfway between their letters and the next line's, since Word draws an exact line's text on screen only inside the line, and the last layer of a shape container, where its line runs past the foot, ends at the foot or below its letters |
+| Line height | `w:spacing w:lineRule="exact"` on every paragraph, cells and list items included — the height the engine measured, not a multiple Word would measure again against a substituted font. Word has one height for a paragraph's lines: it is the tallest line's text, or, where the page sets lines of different heights further apart than that, the page's mean distance between their baselines, with what a paragraph on one page then falls short of the page owed below it (not yet a list item's, or text set over the flow). In a paragraph of more than one line on one page, the spaces a `bulletOffset` sets before a line, written as an indent, do not count towards that line's text. A paragraph with no room above its first line for the gap between lines (one opening a cell or a column) shares the gaps out over all its lines, so its lines stand a little closer than on the page. A paragraph the layout did not measure — one in a composed table cell — is left to the editor, and a line holding a picture above its text is written "at least" that height — except a paragraph of one line of text with room above for its pictures' reach, held exact at the page's height (see "Inline pictures"); in both the paragraph mark is set in the text's size and face, since the mark counts towards the last line's height. A line holding pictures and no text, where they fill the page's line, has its mark and its pictures' runs set at a point instead, so Word makes the line as tall as the pictures and no taller: in the paragraph's own size the mark's depth went under the picture (measured: `SlateOrange`'s skills, a 12.4pt icon beside each label, stood 0.2pt taller each in Word). A smaller picture in a line the page sets at its font's height keeps its mark, and so does a line holding a letter, a break, a tab, a field or a link's text. Both editors stand the baseline of an exact line four fifths of the way down it whatever the face (measured in Word and LibreOffice), and the page sets it the face's ascent below the line's top: where the two are half a point or more apart — a face with a deep descent, as Spectral's is — a paragraph's text is raised or lowered to the page's baseline by `w:position`, matched at its middle line; a list item's and a table text cell's are not yet. A picture among such text moves with it in Word; LibreOffice keeps a picture on its own baseline, where it stood before. Lines a container stacks tighter than their face — a title's lines a pitch apart — each end halfway between their letters and the next line's, since Word draws an exact line's text on screen only inside the line, and the last layer of a shape container, where its line runs past the foot, ends at the foot or below its letters |
| Table columns | the resolved cell widths as `w:gridCol`, with `w:tblLayout` fixed so Word does not re-fit them |
| Row columns | where the layout placed each child, with the row's gap and padding folded into the neighbouring column and taken back out as that cell's margin. A column sized to its content (`DocumentRowColumn.auto()`) gets a point more, taken from the row's weight columns so the row keeps its width, for the reason a table's does: the editor's substitute font would wrap it — a table of contents' labels broke mid-word ("Intr" / "o") in LibreOffice without it. A row with no auto column, no weight column, or no stated columns (weights, an even split) is written as placed |
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 f41759aad..44c9552b1 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
@@ -541,16 +541,33 @@ private OptionalDouble lineHeight(DocumentNode node, boolean byPitch, boolean wi
double style = 0;
double pitches = 0;
int pairs = 0;
- // A line of its own keeps its laid-out height: a paragraph of one line is set at it where
- // nothing would make up a shorter one (riseIntoItsLine, holdPicturesInTheLine).
- boolean prefixed = withoutPrefix && lineCount(node) > 1 && node instanceof ParagraphNode paragraphNode
- && !paragraphNode.bulletOffset().isEmpty()
- && paragraphNode.indentStrategy() != com.demcha.compose.document.style.DocumentTextIndent.NONE;
+ double heights = 0;
+ int textLines = 0;
+ // Only lines on one fragment, of more than one line, are written other than at their
+ // tallest: what they then fall short of the page is owed below them (linesHeight), and a
+ // line of its own is set at its laid-out height where nothing would make up a shorter one
+ // (riseIntoItsLine, holdPicturesInTheLine).
+ boolean ownWay = textFragmentsOf(node).size() == 1 && lineCount(node) > 1;
+ // The layout sets the prefix before the lines its strategy names, and only there.
+ com.demcha.compose.document.style.DocumentTextIndent strategy =
+ withoutPrefix && ownWay && node instanceof ParagraphNode paragraphNode
+ && !paragraphNode.bulletOffset().isEmpty()
+ ? paragraphNode.indentStrategy()
+ : com.demcha.compose.document.style.DocumentTextIndent.NONE;
+ boolean firstPrefixed = strategy == com.demcha.compose.document.style.DocumentTextIndent.FIRST_LINE
+ || strategy == com.demcha.compose.document.style.DocumentTextIndent.ALL_LINES;
+ boolean wrappedPrefixed = strategy == com.demcha.compose.document.style.DocumentTextIndent.FROM_SECOND_LINE
+ || strategy == com.demcha.compose.document.style.DocumentTextIndent.ALL_LINES;
for (PlacedFragment fragment : textFragmentsOf(node)) {
if (fragment.payload() instanceof ParagraphFragmentPayload paragraph) {
ParagraphLine above = null;
for (ParagraphLine line : paragraph.lines()) {
+ boolean prefixed = above == null ? firstPrefixed : wrappedPrefixed;
text = Math.max(text, prefixed ? writtenTextHeight(line) : line.textLineHeight());
+ if (setByText(line)) {
+ heights += line.lineHeight();
+ textLines++;
+ }
// A line a picture makes taller is made room for on its own (makeRoomForPictures).
if (above != null && setByText(above) && setByText(line)) {
pitches += above.baselineOffsetFromBottom() + Math.max(0, paragraph.lineGap())
@@ -568,9 +585,12 @@ private OptionalDouble lineHeight(DocumentNode node, boolean byPitch, boolean wi
// Word sets every line of a paragraph one height apart; the page sets each pair of
// lines its own distance apart. Where those differ, the page's mean distance — less
// the gap Word is given on top (applyLineGap) — keeps the first and last lines where
- // the page has them, never below the tallest written line, which would clip it.
- if (byPitch && pairs > 0) {
- text = Math.max(text, pitches / pairs - Math.max(0, lineGap(node)));
+ // the page has them, never below the tallest written line, which would clip it. Nor
+ // above the lines' mean height: the paragraph would come out taller than the page,
+ // and a surplus cannot be owed back.
+ if (byPitch && ownWay && pairs > 0 && textLines > 0) {
+ double pitch = pitches / pairs - Math.max(0, lineGap(node));
+ text = Math.max(text, Math.min(pitch, heights / textLines));
}
return OptionalDouble.of(text);
}