From d5b8b76ea389b83dc0868672017ed5a5be8728d1 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Thu, 1 Oct 2026 15:23:31 +0100
Subject: [PATCH 1/3] fix(docx): set a paragraph's lines in the measure Word's
half-point size takes, so they break where the page breaks them
---
CHANGELOG.md | 8 ++
docs/recipes/docx-export.md | 10 +-
.../semantic/docx/DocxSemanticBackend.java | 58 +++++++++
.../docx/DocxWordSizeMeasureTest.java | 114 ++++++++++++++++++
4 files changed, 186 insertions(+), 4 deletions(-)
create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxWordSizeMeasureTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 435b3fbb0..30c925887 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,14 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A paragraph breaks its lines where the page does, though Word sets its size to the half
+ point.** Word states a type size in half points, so `EngineeringResume`'s 7.8pt profile was set
+ at 8pt and took a line more, and its 6.9pt skills, set at 7pt, broke "SQL" onto a line of its
+ own: each column stood 8 to 9pt low. A paragraph set flush left is now given a measure as much
+ wider or narrower as Word sets its text, through its right indent; the glyphs are left as
+ Word sets them. In Word, `EngineeringResume`'s median drift falls from 7.9pt to 0.5, and the
+ corpus's lines more than 2pt off fall from 324 to 249.
+
- **`NavySidebar` stands where the page sets it in Word.** Its median drift in Word falls from
5.2pt to 0.1pt, every line within 2pt of the page.
- Its portrait is a 127pt ring round a 123.8pt photo, in a column 123.8pt wide. The photo was
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index b66a03a49..4a399b7d8 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -287,10 +287,12 @@ bold in both.
Word states a type size in half-points, so a size set to the tenth is written to the
nearest half: 9.4pt as 9.5, 7.8pt as 8. A line of it is that much wider or narrower than on
-the page. Measured over twenty CV presets, about one line in eight breaks at another word in
-Word or LibreOffice than on the page; in LibreOffice about a fifth of those break there
-because of the rounding, the rest because of how the editor sets text. The run is not scaled across to make up for it: a
-scale would stay on the text a reader types next.
+the page. The glyphs are not scaled to make up for it, since a scale would stay on the text
+a reader types next. A paragraph set flush left is given a measure that much wider or
+narrower instead, through its right indent, so its lines break at the words the page breaks
+them; a reader's new text wraps at that measure. A centred or right-aligned paragraph keeps
+the page's measure, as moving its other edge would move its lines. A list's items, a line pair's
+line, text over the flow and a header's or footer's line keep the page's measure too.
## Named styles, so the document can be restyled
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 be4e4cab6..542d69920 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
@@ -4596,6 +4596,61 @@ private void letTheLineStandOut(XWPFParagraph para, ParagraphNode node, double r
indent.setRight(BigInteger.valueOf(right - toTwips(overhang + EDITOR_SLACK_POINTS)));
}
+ /**
+ * Sets a paragraph's lines in a measure as much wider or narrower than the page's as Word
+ * sets its text, so they break at the words the page breaks them.
+ *
+ * Word states a type size in half points, so a size the page sets to the tenth is set a
+ * little larger or smaller, and its lines that much wider or narrower: {@code
+ * EngineeringResume}'s 6.9pt skills, set at 7pt, broke "SQL" onto a line of its own, and its
+ * 7.8pt profile, set at 8pt, took a line more, each column standing 8 to 9pt low under it.
+ * The glyphs are left as Word sets them — a scale would stay on the text a reader types
+ * next — and the right indent gives the line the same share more room, or takes it.
+ *
+ * Only a paragraph set flush left: moving a centred or right-aligned line's other edge
+ * would move the line off where the page sets it. A list's items, a line pair, text over
+ * the flow and a header's or footer's line are written elsewhere and keep the page's
+ * measure.
+ *
+ * @param room the width the paragraph's text is written in, in points
+ */
+ private void measureAtWordsSize(XWPFParagraph para, ParagraphNode node, double room) {
+ if (node.align() == TextAlign.CENTER || node.align() == TextAlign.RIGHT || !Double.isFinite(room)) {
+ return;
+ }
+ double set = 0;
+ double asked = 0;
+ for (InlineRun run : node.inlineRuns()) {
+ InlineTextRun text = textOf(run);
+ if (text == null) {
+ continue;
+ }
+ DocumentTextStyle style = text.textStyle() == null ? node.textStyle() : text.textStyle();
+ double size = style == null ? 0 : style.size();
+ if (size > 0) {
+ int characters = text.text().length();
+ asked += characters * size;
+ set += characters * Math.max(1, Math.round(size * HALF_POINTS_PER_POINT)) / HALF_POINTS_PER_POINT;
+ }
+ }
+ if (node.inlineRuns().isEmpty() && node.textStyle() != null && node.textStyle().size() > 0) {
+ // No text runs: the paragraph's own text, in its own style.
+ asked = node.textStyle().size();
+ set = Math.max(1, Math.round(asked * HALF_POINTS_PER_POINT)) / HALF_POINTS_PER_POINT;
+ }
+ if (!(asked > 0)) {
+ return;
+ }
+ double more = room * (set / asked - 1);
+ if (Math.abs(more) < 0.05) {
+ return;
+ }
+ CTPPr properties = para.getCTP().isSetPPr() ? para.getCTP().getPPr() : para.getCTP().addNewPPr();
+ CTInd indent = properties.isSetInd() ? properties.getInd() : properties.addNewInd();
+ long right = twipsOf(indent.isSetRight() ? indent.getRight() : null);
+ indent.setRight(BigInteger.valueOf(right - Math.round(more * POINT_TO_TWIP)));
+ }
+
/**
* A paragraph's left indent in twips: below zero in the body, where a container hanging
* left by a negative margin takes its text out past the margin as the page does; never
@@ -4995,6 +5050,9 @@ private void writeParagraph(XWPFDocument document, ParagraphNode node) {
}
letTheLineStandOut(para, node, room);
boolean rightToLeft = applyParagraphProperties(para, node);
+ if (!rightToLeft) {
+ measureAtWordsSize(para, node, room);
+ }
applyHeadingRole(para, node);
int anchor = openAnchor(para, node.anchor());
// A line of a stack starts where its letters and the ones above leave room, not where
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxWordSizeMeasureTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxWordSizeMeasureTest.java
new file mode 100644
index 000000000..29f41f723
--- /dev/null
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxWordSizeMeasureTest.java
@@ -0,0 +1,114 @@
+package com.demcha.compose.document.backend.semantic.docx;
+
+import com.demcha.compose.document.node.TextAlign;
+import com.demcha.compose.document.style.DocumentRowColumn;
+import com.demcha.compose.document.style.DocumentTextStyle;
+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 static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * A paragraph's lines are set in a measure as much wider or narrower than the page's as Word
+ * sets its text.
+ *
+ * Word states a type size in half points. {@code EngineeringResume}'s 7.8pt profile, set at
+ * 8pt, took a line more than on the page, and its 6.9pt skills broke a word onto a line of its
+ * own: each column stood 8 to 9pt low under them.
+ */
+class DocxWordSizeMeasureTest {
+
+ /** The body's width on a 400pt page with 20pt margins. */
+ private static final double ROOM = 360;
+
+ @Test
+ void aSizeWordSetsLargerWidensTheMeasureByAsMuch() throws Exception {
+ assertThat(rightIndent(paragraph(7.8, TextAlign.LEFT)))
+ .as("the measure 8/7.8 of the page's")
+ .isEqualTo(-Math.round(ROOM * (8 / 7.8 - 1) * 20));
+ }
+
+ @Test
+ void aSizeWordSetsSmallerNarrowsIt() throws Exception {
+ assertThat(rightIndent(paragraph(9.2, TextAlign.LEFT)))
+ .isEqualTo(Math.round(ROOM * (1 - 9 / 9.2) * 20));
+ }
+
+ @Test
+ void aSizeWordStatesAsItIsLeavesTheMeasureAlone() throws Exception {
+ assertThat(rightIndent(paragraph(9.5, TextAlign.LEFT))).isZero();
+ }
+
+ @Test
+ void aCentredLineIsLeftWhereThePageSetsIt() throws Exception {
+ assertThat(rightIndent(paragraph(7.8, TextAlign.CENTER))).isZero();
+ }
+
+ @Test
+ void runsOfTwoSizesShareTheMeasureByTheirLetters() throws Exception {
+ // Ten letters at 7.8pt, set at 8, and ten at 9pt, set as they are.
+ try (XWPFDocument document = DocxExports.withLayout(400, 400, 20, page -> page
+ .addParagraph(p -> p.rich(rich -> rich
+ .size("abcdefghij", 7.8)
+ .size("klmnopqrst", 9))))) {
+ double share = (10 * 8.0 + 10 * 9.0) / (10 * 7.8 + 10 * 9.0);
+
+ assertThat(rightIndent(text(document, "abcdefghij")))
+ .isEqualTo(-Math.round(ROOM * (share - 1) * 20));
+ }
+ }
+
+ @Test
+ void aRunOfItsOwnSizeIsWrittenAtTheSizeTheMeasureIsTakenFor() throws Exception {
+ // The body sets Normal at 12pt; the small print states its own size on its run.
+ try (XWPFDocument document = DocxExports.withLayout(400, 400, 20, page -> page
+ .addParagraph(p -> p.text("A body paragraph long enough to set the document's own size.")
+ .textStyle(DocumentTextStyle.DEFAULT.withSize(12)))
+ .addParagraph(p -> p.text("Platform engineer with ten years of document pipelines.")
+ .textStyle(DocumentTextStyle.DEFAULT.withSize(7.8))))) {
+ XWPFParagraph small = text(document, "Platform engineer");
+
+ assertThat(DocxTwips.of(small.getRuns().get(0).getCTR().getRPr().getSzArray(0).getVal()))
+ .as("8pt, in half points").isEqualTo(16L);
+ assertThat(rightIndent(small)).isEqualTo(-Math.round(ROOM * (8 / 7.8 - 1) * 20));
+ }
+ }
+
+ @Test
+ void aParagraphInACellIsGivenItsShareOfTheCellsMeasure() throws Exception {
+ try (XWPFDocument document = DocxExports.withLayout(400, 400, 20, page -> page
+ .addRow("Page", row -> row
+ .columns(DocumentRowColumn.fixed(200), DocumentRowColumn.weight(1))
+ .addParagraph(p -> p.text("Platform engineer with ten years of document pipelines.")
+ .textStyle(DocumentTextStyle.DEFAULT.withSize(7.8)))
+ .addParagraph("Main")))) {
+ XWPFParagraph cell = document.getTables().get(0).getRow(0).getCell(0).getParagraphs().stream()
+ .filter(p -> p.getText().startsWith("Platform engineer")).findFirst().orElseThrow();
+
+ assertThat(-rightIndent(cell)).as("wider, by its share of a column no wider than 200pt")
+ .isPositive()
+ .isLessThanOrEqualTo(Math.round(200 * (8 / 7.8 - 1) * 20));
+ }
+ }
+
+ private static long rightIndent(XWPFParagraph paragraph) {
+ CTInd indent = paragraph.getCTP().getPPr() == null ? null : paragraph.getCTP().getPPr().getInd();
+ return indent == null || !indent.isSetRight() ? 0 : DocxTwips.of(indent.getRight());
+ }
+
+ private static XWPFParagraph paragraph(double size, TextAlign align) throws Exception {
+ XWPFDocument document = DocxExports.withLayout(400, 400, 20, page -> page
+ .addParagraph(p -> p.text("Platform engineer with ten years of document pipelines.")
+ .textStyle(DocumentTextStyle.DEFAULT.withSize(size))
+ .align(align)));
+ return text(document, "Platform engineer");
+ }
+
+ private static XWPFParagraph text(XWPFDocument document, String start) {
+ return document.getParagraphs().stream()
+ .filter(p -> p.getText().startsWith(start))
+ .findFirst().orElseThrow();
+ }
+}
From ec8cc6c2cfb06aeb6c072d6c0b39397805e922a3 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Thu, 1 Oct 2026 16:32:45 +0100
Subject: [PATCH 2/3] fix(docx): take Word's measure from the line that grows
most, and hold a wrapped paragraph's a point clear of the next word
---
CHANGELOG.md | 8 +-
docs/recipes/docx-export.md | 4 +-
.../semantic/docx/DocxSemanticBackend.java | 102 ++++++++++++++----
.../docx/DocxWordSizeMeasureTest.java | 91 ++++++++++++++--
4 files changed, 171 insertions(+), 34 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 30c925887..0f1bcb4ef 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,8 +13,12 @@ follow semantic versioning; release dates are ISO 8601.
at 8pt and took a line more, and its 6.9pt skills, set at 7pt, broke "SQL" onto a line of its
own: each column stood 8 to 9pt low. A paragraph set flush left is now given a measure as much
wider or narrower as Word sets its text, through its right indent; the glyphs are left as
- Word sets them. In Word, `EngineeringResume`'s median drift falls from 7.9pt to 0.5, and the
- corpus's lines more than 2pt off fall from 324 to 249.
+ Word sets them. The measure follows the line that grows most, so a 7.35pt title, set at 7.5,
+ still fits over 7.1pt prose set at 7; and a paragraph of several lines keeps it a point short
+ of the full share, so a word the page broke off by a fraction of a point stays broken off.
+ In Word, `EngineeringResume`'s median drift falls from 7.9pt to 0.6, and the corpus's lines
+ more than 2pt off fall from 324 to 249; line by line no line moves further from the page in
+ Word or LibreOffice.
- **`NavySidebar` stands where the page sets it in Word.** Its median drift in Word falls from
5.2pt to 0.1pt, every line within 2pt of the page.
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 4a399b7d8..cf47c390a 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -290,7 +290,9 @@ nearest half: 9.4pt as 9.5, 7.8pt as 8. A line of it is that much wider or narro
the page. The glyphs are not scaled to make up for it, since a scale would stay on the text
a reader types next. A paragraph set flush left is given a measure that much wider or
narrower instead, through its right indent, so its lines break at the words the page breaks
-them; a reader's new text wraps at that measure. A centred or right-aligned paragraph keeps
+them; a reader's new text wraps at that measure. The measure follows the line Word sets widest
+for its size, and in a paragraph of several lines stays a point short of it, so a word the page
+broke off by a fraction of a point is not pulled back up. A centred or right-aligned paragraph keeps
the page's measure, as moving its other edge would move its lines. A list's items, a line pair's
line, text over the flow and a header's or footer's line keep the page's measure too.
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 542d69920..79e342d02 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
@@ -4618,37 +4618,95 @@ private void measureAtWordsSize(XWPFParagraph para, ParagraphNode node, double r
if (node.align() == TextAlign.CENTER || node.align() == TextAlign.RIGHT || !Double.isFinite(room)) {
return;
}
- double set = 0;
+ double more = wordsMeasure(node, room) - room;
+ if (!Double.isFinite(more) || Math.abs(more) < 0.05) {
+ return;
+ }
+ CTPPr properties = para.getCTP().isSetPPr() ? para.getCTP().getPPr() : para.getCTP().addNewPPr();
+ CTInd indent = properties.isSetInd() ? properties.getInd() : properties.addNewInd();
+ long right = twipsOf(indent.isSetRight() ? indent.getRight() : null);
+ indent.setRight(BigInteger.valueOf(right - Math.round(more * POINT_TO_TWIP)));
+ }
+
+ /** How far inside the page's measure, grown or shrunk as Word sets it, Word's is held, in points. */
+ private static final double WORDS_MEASURE_CLEARANCE = 1;
+
+ /**
+ * The measure Word is to set a paragraph's text in, in points: the page's, as much wider or
+ * narrower as Word sets the line that grows most, a point short of it, and never short of
+ * the widest line.
+ *
+ * Each line the page laid out is weighed by its own text: a line of a 7.35pt title set at
+ * 7.5 grows, the 7.1pt lines under it set at 7 shrink, and a share averaged over the
+ * paragraph would narrow the measure the title's line no longer fits. The measure takes the
+ * share of the line that grows most, so every line still fits.
+ *
+ * A line the page broke because its next word did not fit may have missed by a fraction
+ * of a point, and grown in the same proportion it misses by as little in Word, where it can
+ * fit: {@code CompactMono}'s "and", 0.1pt from fitting on the page, fitted in Word. Held a
+ * point short, the measure misses every such word by at least that; held a point past the
+ * widest line, it still fits every line. A paragraph of one line broke no word, and keeps
+ * the measure grown in full: {@code OrangeOps}' phone number, as wide as its column, broke
+ * in LibreOffice a point narrower. Without the page's lines — an export with no
+ * layout — the paragraph's runs are weighed by their letters.
+ */
+ private double wordsMeasure(ParagraphNode node, double room) {
+ double share = Double.NaN;
+ double fits = 0;
+ int broken = -1;
+ for (com.demcha.compose.document.layout.payloads.ParagraphLine line : layout.lines(node)) {
+ broken++;
+ double asked = 0;
+ double set = 0;
+ for (com.demcha.compose.document.layout.payloads.ParagraphSpan span : line.spans()) {
+ if (span instanceof com.demcha.compose.document.layout.payloads.ParagraphTextSpan text
+ && text.textStyle() != null && text.textStyle().size() > 0 && text.width() > 0) {
+ asked += text.width();
+ set += text.width() * wordsSize(text.textStyle().size()) / text.textStyle().size();
+ }
+ }
+ if (asked > 0) {
+ share = Double.isNaN(share) ? set / asked : Math.max(share, set / asked);
+ fits = Math.max(fits, set);
+ }
+ }
+ if (Double.isNaN(share)) {
+ return room * lettersShare(node);
+ }
+ if (Math.abs(share - 1) < 1e-9) {
+ // Word sets every line at the page's size: the page's measure is Word's.
+ return room;
+ }
+ if (broken < 1) {
+ // One line: no word the page broke to keep out, and room to spare for the editor's face.
+ return Math.max(room * share, fits);
+ }
+ return Math.max(room * share - WORDS_MEASURE_CLEARANCE, fits + WORDS_MEASURE_CLEARANCE);
+ }
+
+ /** How much wider Word sets a paragraph's runs, weighed by their letters, 1 for as wide. */
+ private double lettersShare(ParagraphNode node) {
double asked = 0;
+ double set = 0;
for (InlineRun run : node.inlineRuns()) {
InlineTextRun text = textOf(run);
- if (text == null) {
- continue;
- }
- DocumentTextStyle style = text.textStyle() == null ? node.textStyle() : text.textStyle();
- double size = style == null ? 0 : style.size();
- if (size > 0) {
- int characters = text.text().length();
- asked += characters * size;
- set += characters * Math.max(1, Math.round(size * HALF_POINTS_PER_POINT)) / HALF_POINTS_PER_POINT;
+ DocumentTextStyle style = text == null ? null : text.textStyle() == null ? node.textStyle() : text.textStyle();
+ if (style != null && style.size() > 0) {
+ asked += text.text().length() * style.size();
+ set += text.text().length() * wordsSize(style.size());
}
}
if (node.inlineRuns().isEmpty() && node.textStyle() != null && node.textStyle().size() > 0) {
// No text runs: the paragraph's own text, in its own style.
asked = node.textStyle().size();
- set = Math.max(1, Math.round(asked * HALF_POINTS_PER_POINT)) / HALF_POINTS_PER_POINT;
+ set = wordsSize(asked);
}
- if (!(asked > 0)) {
- return;
- }
- double more = room * (set / asked - 1);
- if (Math.abs(more) < 0.05) {
- return;
- }
- CTPPr properties = para.getCTP().isSetPPr() ? para.getCTP().getPPr() : para.getCTP().addNewPPr();
- CTInd indent = properties.isSetInd() ? properties.getInd() : properties.addNewInd();
- long right = twipsOf(indent.isSetRight() ? indent.getRight() : null);
- indent.setRight(BigInteger.valueOf(right - Math.round(more * POINT_TO_TWIP)));
+ return asked > 0 ? set / asked : 1;
+ }
+
+ /** The size Word sets a size in, to the half point, as {@code w:sz} states it. */
+ private static double wordsSize(double size) {
+ return Math.max(1, Math.round(size * HALF_POINTS_PER_POINT)) / HALF_POINTS_PER_POINT;
}
/**
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxWordSizeMeasureTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxWordSizeMeasureTest.java
index 29f41f723..2a9431dd4 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxWordSizeMeasureTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxWordSizeMeasureTest.java
@@ -1,6 +1,11 @@
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.layout.payloads.ParagraphFragmentPayload;
+import com.demcha.compose.document.layout.payloads.ParagraphLine;
import com.demcha.compose.document.node.TextAlign;
+import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.style.DocumentRowColumn;
import com.demcha.compose.document.style.DocumentTextStyle;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
@@ -8,6 +13,8 @@
import org.junit.jupiter.api.Test;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTInd;
+import java.util.List;
+
import static org.assertj.core.api.Assertions.assertThat;
/**
@@ -26,7 +33,7 @@ class DocxWordSizeMeasureTest {
@Test
void aSizeWordSetsLargerWidensTheMeasureByAsMuch() throws Exception {
assertThat(rightIndent(paragraph(7.8, TextAlign.LEFT)))
- .as("the measure 8/7.8 of the page's")
+ .as("one line: the measure 8/7.8 of the page's, in full")
.isEqualTo(-Math.round(ROOM * (8 / 7.8 - 1) * 20));
}
@@ -47,16 +54,19 @@ void aCentredLineIsLeftWhereThePageSetsIt() throws Exception {
}
@Test
- void runsOfTwoSizesShareTheMeasureByTheirLetters() throws Exception {
- // Ten letters at 7.8pt, set at 8, and ten at 9pt, set as they are.
+ void theMeasureIsTheOneTheLineThatGrowsMostNeeds() throws Exception {
+ // EngineeringResume's projects: a 7.35pt title, set at 7.5, fills most of the first
+ // line, and 7.1pt prose, set at 7, the lines under it. Averaged over the paragraph's
+ // letters the measure would narrow, and the title's line would no longer fit.
+ String title = "GraphCompose (Java 21, PDFBox, Maven, JMH) - Declarative Java PDF layout";
+ String prose = " engine. Semantic templates, snapshot testing, and the pipelines that run on them ".repeat(4);
try (XWPFDocument document = DocxExports.withLayout(400, 400, 20, page -> page
- .addParagraph(p -> p.rich(rich -> rich
- .size("abcdefghij", 7.8)
- .size("klmnopqrst", 9))))) {
- double share = (10 * 8.0 + 10 * 9.0) / (10 * 7.8 + 10 * 9.0);
+ .addParagraph(p -> p.rich(rich -> rich.size(title, 7.35).size(prose, 7.1))))) {
+ double letters = (title.length() * 7.5 + prose.length() * 7.0) / (title.length() * 7.35 + prose.length() * 7.1);
- assertThat(rightIndent(text(document, "abcdefghij")))
- .isEqualTo(-Math.round(ROOM * (share - 1) * 20));
+ assertThat(letters).as("the letters' average narrows").isLessThan(1);
+ assertThat(rightIndent(text(document, "GraphCompose")))
+ .as("the title's line is given the room it grows by").isNegative();
}
}
@@ -93,6 +103,69 @@ void aParagraphInACellIsGivenItsShareOfTheCellsMeasure() throws Exception {
}
}
+ @Test
+ void aLineThePageBrokeJustShortOfItsNextWordStaysBroken() throws Exception {
+ // CompactMono's "and" was 0.1pt from fitting on the page; given the measure in
+ // proportion, Word fitted it. Find a measure as close, then hold Word's clear of it.
+ String text = "Built backend services and production document rendering pipelines processing two "
+ + "million documents per month and cutting render latency from seconds to milliseconds.";
+ for (double width = 300; width < 360; width += 0.05) {
+ double room = width;
+ List lines = laidOut(room, text);
+ double[] tie = lineAndNextWord(lines);
+ if (tie == null || !(tie[1] - room > 0) || !(tie[1] - room < 0.2)) {
+ continue;
+ }
+ try (XWPFDocument document = DocxExports.withLayout(room + 40, 400, 20, page -> page
+ .addParagraph(p -> p.text(text).textStyle(DocumentTextStyle.DEFAULT.withSize(7.95))))) {
+ double measure = room - rightIndent(text(document, "Built backend")) / 20.0;
+ double grows = 8 / 7.95;
+
+ assertThat(measure).as("every line fits").isGreaterThanOrEqualTo(tie[0] * grows);
+ assertThat(measure).as("the next word does not, by a point")
+ .isLessThanOrEqualTo(tie[1] * grows - 0.99);
+ }
+ return;
+ }
+ throw new AssertionError("no measure within 0.2pt of a line and its next word");
+ }
+
+ /**
+ * The widest line, and the narrowest line with a space and its next line's first word, as
+ * the page lays them out; a word's width is its own line's, laid out alone.
+ */
+ private static double[] lineAndNextWord(List lines) {
+ double widest = 0;
+ double nearest = Double.POSITIVE_INFINITY;
+ double space = widthAlone("a a") - 2 * widthAlone("a");
+ for (int index = 0; index < lines.size(); index++) {
+ widest = Math.max(widest, lines.get(index).width());
+ if (index + 1 < lines.size()) {
+ String next = lines.get(index + 1).text().strip().split("\\s+")[0];
+ nearest = Math.min(nearest, lines.get(index).width() + space + widthAlone(next));
+ }
+ }
+ return lines.size() < 2 ? null : new double[]{widest, nearest};
+ }
+
+ private static double widthAlone(String text) {
+ return laidOut(500, text).get(0).width();
+ }
+
+ private static List laidOut(double room, String text) {
+ try (DocumentSession session = GraphCompose.document().pageSize(room + 40, 400)
+ .margin(DocumentInsets.of(20)).create()) {
+ session.pageFlow(page -> page.addParagraph(p -> p.text(text)
+ .textStyle(DocumentTextStyle.DEFAULT.withSize(7.95))));
+ return session.layoutGraph().fragments().stream()
+ .filter(fragment -> fragment.payload() instanceof ParagraphFragmentPayload)
+ .flatMap(fragment -> ((ParagraphFragmentPayload) fragment.payload()).lines().stream())
+ .toList();
+ } catch (Exception failure) {
+ throw new IllegalStateException(failure);
+ }
+ }
+
private static long rightIndent(XWPFParagraph paragraph) {
CTInd indent = paragraph.getCTP().getPPr() == null ? null : paragraph.getCTP().getPPr().getInd();
return indent == null || !indent.isSetRight() ? 0 : DocxTwips.of(indent.getRight());
From 1b161a19e544089382da4df1a450527bc28a8b07 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Thu, 1 Oct 2026 17:00:47 +0100
Subject: [PATCH 3/3] fix(docx): count a line's pictures and tracking at their
own width, and never narrow a paragraph of one line
---
CHANGELOG.md | 8 +-
docs/recipes/docx-export.md | 8 +-
.../semantic/docx/DocxSemanticBackend.java | 55 +++++----
.../docx/DocxWordSizeMeasureTest.java | 113 ++++++++++++++----
4 files changed, 136 insertions(+), 48 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0f1bcb4ef..19083a4a0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,9 +13,11 @@ follow semantic versioning; release dates are ISO 8601.
at 8pt and took a line more, and its 6.9pt skills, set at 7pt, broke "SQL" onto a line of its
own: each column stood 8 to 9pt low. A paragraph set flush left is now given a measure as much
wider or narrower as Word sets its text, through its right indent; the glyphs are left as
- Word sets them. The measure follows the line that grows most, so a 7.35pt title, set at 7.5,
- still fits over 7.1pt prose set at 7; and a paragraph of several lines keeps it a point short
- of the full share, so a word the page broke off by a fraction of a point stays broken off.
+ Word sets them. The measure grows by the share of the line that grows most, so a 7.35pt
+ title, set at 7.5, still fits over 7.1pt prose set at 7, and every line keeps a point to
+ spare; a paragraph of several lines is held a point short of the full share where its lines
+ allow, so a word the page broke off by a fraction of a point stays broken off; a paragraph of
+ one line is never narrowed.
In Word, `EngineeringResume`'s median drift falls from 7.9pt to 0.6, and the corpus's lines
more than 2pt off fall from 324 to 249; line by line no line moves further from the page in
Word or LibreOffice.
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index cf47c390a..adc35793b 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -290,9 +290,11 @@ nearest half: 9.4pt as 9.5, 7.8pt as 8. A line of it is that much wider or narro
the page. The glyphs are not scaled to make up for it, since a scale would stay on the text
a reader types next. A paragraph set flush left is given a measure that much wider or
narrower instead, through its right indent, so its lines break at the words the page breaks
-them; a reader's new text wraps at that measure. The measure follows the line Word sets widest
-for its size, and in a paragraph of several lines stays a point short of it, so a word the page
-broke off by a fraction of a point is not pulled back up. A centred or right-aligned paragraph keeps
+them; a reader's new text wraps at that measure. The measure grows by the share of the line
+that grows most for its size — pictures and tracking keep their own width — and in a paragraph
+of several lines stays a point short of that, unless a line needs the room, so a word the page
+broke off by a fraction of a point is not pulled back up. A paragraph of one line is never
+narrowed. A centred or right-aligned paragraph keeps
the page's measure, as moving its other edge would move its lines. A list's items, a line pair's
line, text over the flow and a header's or footer's line keep the page's measure too.
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 79e342d02..ffff79263 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
@@ -4581,19 +4581,21 @@ private double availableWidth() {
* or move a centred or right-aligned line off where the page sets it.
*
* @param room the width the paragraph's text is written in, in points
+ * @return whether the paragraph was given room past its box
*/
- private void letTheLineStandOut(XWPFParagraph para, ParagraphNode node, double room) {
+ private boolean letTheLineStandOut(XWPFParagraph para, ParagraphNode node, double room) {
if (node.align() == TextAlign.CENTER || node.align() == TextAlign.RIGHT) {
- return;
+ return false;
}
double overhang = layout.unbrokenWidth(node) - room;
if (!Double.isFinite(overhang) || !(overhang > 0.01)) {
- return;
+ return false;
}
CTPPr properties = para.getCTP().isSetPPr() ? para.getCTP().getPPr() : para.getCTP().addNewPPr();
CTInd indent = properties.isSetInd() ? properties.getInd() : properties.addNewInd();
long right = twipsOf(indent.isSetRight() ? indent.getRight() : null);
indent.setRight(BigInteger.valueOf(right - toTwips(overhang + EDITOR_SLACK_POINTS)));
+ return true;
}
/**
@@ -4633,22 +4635,25 @@ private void measureAtWordsSize(XWPFParagraph para, ParagraphNode node, double r
/**
* The measure Word is to set a paragraph's text in, in points: the page's, as much wider or
- * narrower as Word sets the line that grows most, a point short of it, and never short of
- * the widest line.
+ * narrower as Word sets the line that grows most, a point short of that in a paragraph of
+ * several lines unless one of its lines needs more, and never less than a point past the
+ * widest line.
*
* Each line the page laid out is weighed by its own text: a line of a 7.35pt title set at
* 7.5 grows, the 7.1pt lines under it set at 7 shrink, and a share averaged over the
- * paragraph would narrow the measure the title's line no longer fits. The measure takes the
- * share of the line that grows most, so every line still fits.
+ * paragraph would narrow the measure the title's line no longer fits. A picture or a shape
+ * in a line is written at its own size and takes the same room in Word, and tracking is
+ * written in points, so neither grows with the size.
*
* A line the page broke because its next word did not fit may have missed by a fraction
* of a point, and grown in the same proportion it misses by as little in Word, where it can
* fit: {@code CompactMono}'s "and", 0.1pt from fitting on the page, fitted in Word. Held a
- * point short, the measure misses every such word by at least that; held a point past the
- * widest line, it still fits every line. A paragraph of one line broke no word, and keeps
- * the measure grown in full: {@code OrangeOps}' phone number, as wide as its column, broke
- * in LibreOffice a point narrower. Without the page's lines — an export with no
- * layout — the paragraph's runs are weighed by their letters.
+ * point short, the measure misses such a word by that much more. The widest line, grown,
+ * still has a point to spare, which wins where the two meet. A paragraph of one line broke
+ * no word: it keeps its measure or the share it grows by, whichever is wider, so a line as
+ * wide as its column is never narrowed onto two — {@code OrangeOps}' phone number broke in
+ * LibreOffice a point narrower. Without the page's lines — an export with no layout — the
+ * paragraph's runs are weighed by their letters.
*/
private double wordsMeasure(ParagraphNode node, double room) {
double share = Double.NaN;
@@ -4658,14 +4663,22 @@ private double wordsMeasure(ParagraphNode node, double room) {
broken++;
double asked = 0;
double set = 0;
+ boolean text = false;
for (com.demcha.compose.document.layout.payloads.ParagraphSpan span : line.spans()) {
- if (span instanceof com.demcha.compose.document.layout.payloads.ParagraphTextSpan text
- && text.textStyle() != null && text.textStyle().size() > 0 && text.width() > 0) {
- asked += text.width();
- set += text.width() * wordsSize(text.textStyle().size()) / text.textStyle().size();
+ if (!(span.width() > 0)) {
+ continue;
+ }
+ asked += span.width();
+ if (span instanceof com.demcha.compose.document.layout.payloads.ParagraphTextSpan run
+ && run.textStyle() != null && run.textStyle().size() > 0) {
+ double tracking = run.textStyle().letterSpacing() * run.text().codePointCount(0, run.text().length());
+ set += (span.width() - tracking) * wordsSize(run.textStyle().size()) / run.textStyle().size() + tracking;
+ text = true;
+ } else {
+ set += span.width();
}
}
- if (asked > 0) {
+ if (text) {
share = Double.isNaN(share) ? set / asked : Math.max(share, set / asked);
fits = Math.max(fits, set);
}
@@ -4678,8 +4691,7 @@ private double wordsMeasure(ParagraphNode node, double room) {
return room;
}
if (broken < 1) {
- // One line: no word the page broke to keep out, and room to spare for the editor's face.
- return Math.max(room * share, fits);
+ return Math.max(room, Math.max(room * share, fits));
}
return Math.max(room * share - WORDS_MEASURE_CLEARANCE, fits + WORDS_MEASURE_CLEARANCE);
}
@@ -5106,9 +5118,10 @@ private void writeParagraph(XWPFDocument document, ParagraphNode node) {
insetLeft = outerLeft;
insetRight = outerRight;
}
- letTheLineStandOut(para, node, room);
+ boolean standsOut = letTheLineStandOut(para, node, room);
boolean rightToLeft = applyParagraphProperties(para, node);
- if (!rightToLeft) {
+ // A line that stands out was given its room, and a couple of points more.
+ if (!rightToLeft && !standsOut) {
measureAtWordsSize(para, node, room);
}
applyHeadingRole(para, node);
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxWordSizeMeasureTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxWordSizeMeasureTest.java
index 2a9431dd4..c891e5801 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxWordSizeMeasureTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxWordSizeMeasureTest.java
@@ -32,25 +32,31 @@ class DocxWordSizeMeasureTest {
@Test
void aSizeWordSetsLargerWidensTheMeasureByAsMuch() throws Exception {
- assertThat(rightIndent(paragraph(7.8, TextAlign.LEFT)))
+ assertThat(indentOf(7.8, TextAlign.LEFT, SHORT))
.as("one line: the measure 8/7.8 of the page's, in full")
.isEqualTo(-Math.round(ROOM * (8 / 7.8 - 1) * 20));
}
@Test
- void aSizeWordSetsSmallerNarrowsIt() throws Exception {
- assertThat(rightIndent(paragraph(9.2, TextAlign.LEFT)))
- .isEqualTo(Math.round(ROOM * (1 - 9 / 9.2) * 20));
+ void aSizeWordSetsSmallerNarrowsAParagraphOfSeveralLines() throws Exception {
+ assertThat(indentOf(9.2, TextAlign.LEFT, LONG)).as("9/9.2 of the page's, a point short of it")
+ .isEqualTo(Math.round((ROOM * (1 - 9 / 9.2) + 1) * 20));
+ }
+
+ @Test
+ void aParagraphOfOneLineIsNeverNarrowed() throws Exception {
+ // It broke no word to keep out, and narrowed it could break onto two.
+ assertThat(indentOf(9.2, TextAlign.LEFT, SHORT)).isZero();
}
@Test
void aSizeWordStatesAsItIsLeavesTheMeasureAlone() throws Exception {
- assertThat(rightIndent(paragraph(9.5, TextAlign.LEFT))).isZero();
+ assertThat(indentOf(9.5, TextAlign.LEFT, LONG)).isZero();
}
@Test
void aCentredLineIsLeftWhereThePageSetsIt() throws Exception {
- assertThat(rightIndent(paragraph(7.8, TextAlign.CENTER))).isZero();
+ assertThat(indentOf(7.8, TextAlign.CENTER, LONG)).isZero();
}
@Test
@@ -60,14 +66,25 @@ void theMeasureIsTheOneTheLineThatGrowsMostNeeds() throws Exception {
// letters the measure would narrow, and the title's line would no longer fit.
String title = "GraphCompose (Java 21, PDFBox, Maven, JMH) - Declarative Java PDF layout";
String prose = " engine. Semantic templates, snapshot testing, and the pipelines that run on them ".repeat(4);
- try (XWPFDocument document = DocxExports.withLayout(400, 400, 20, page -> page
- .addParagraph(p -> p.rich(rich -> rich.size(title, 7.35).size(prose, 7.1))))) {
- double letters = (title.length() * 7.5 + prose.length() * 7.0) / (title.length() * 7.35 + prose.length() * 7.1);
+ java.util.function.Consumer paragraph =
+ p -> p.rich(rich -> rich.size(title, 7.35).size(prose, 7.1));
+ double letters = (title.length() * 7.5 + prose.length() * 7.0) / (title.length() * 7.35 + prose.length() * 7.1);
+ assertThat(letters).as("the letters' average narrows").isLessThan(1);
+ for (double room = 300; room < 360; room += 0.25) {
+ double first = wordsWidth(laidOut(room, paragraph).get(0));
+ if (!(first > room * letters)) {
+ continue;
+ }
+ // A measure where the title's line, as Word sets it, is wider than the averaged one.
+ double width = room;
+ try (XWPFDocument document = DocxExports.withLayout(room + 40, 400, 20, page -> page.addParagraph(paragraph))) {
+ double measure = width - rightIndent(text(document, "GraphCompose")) / 20.0;
- assertThat(letters).as("the letters' average narrows").isLessThan(1);
- assertThat(rightIndent(text(document, "GraphCompose")))
- .as("the title's line is given the room it grows by").isNegative();
+ assertThat(measure).as("the title's line fits, a point to spare").isGreaterThanOrEqualTo(first + 0.99);
+ }
+ return;
}
+ throw new AssertionError("no measure where the averaged share would cut the title's line");
}
@Test
@@ -121,6 +138,7 @@ void aLineThePageBrokeJustShortOfItsNextWordStaysBroken() throws Exception {
double measure = room - rightIndent(text(document, "Built backend")) / 20.0;
double grows = 8 / 7.95;
+ assertThat(rightIndent(text(document, "Built backend"))).as("wider than the page's").isNegative();
assertThat(measure).as("every line fits").isGreaterThanOrEqualTo(tie[0] * grows);
assertThat(measure).as("the next word does not, by a point")
.isLessThanOrEqualTo(tie[1] * grows - 0.99);
@@ -130,6 +148,51 @@ void aLineThePageBrokeJustShortOfItsNextWordStaysBroken() throws Exception {
throw new AssertionError("no measure within 0.2pt of a line and its next word");
}
+ @Test
+ void everyLineHoldingAPictureStillFits() throws Exception {
+ // A picture is written at its own size: it takes the room it takes on the page. Over a
+ // run of measures one finds the picture's line all but full.
+ String prose = "Built backend services and production document rendering pipelines that process "
+ + "two million documents a month for hiring, billing and reporting teams.";
+ java.util.function.Consumer paragraph = p -> p.rich(rich -> rich
+ .image(com.demcha.compose.document.image.DocumentImageData.fromBytes(pngBytes()), 24, 8)
+ .size(" " + prose, 7.1));
+ double tightest = Double.POSITIVE_INFINITY;
+ for (double room = 300; room < 330; room += 0.25) {
+ double widest = laidOut(room, paragraph).stream().mapToDouble(DocxWordSizeMeasureTest::wordsWidth).max().orElseThrow();
+ double width = room;
+ try (XWPFDocument document = DocxExports.withLayout(room + 40, 400, 20, page -> page.addParagraph(paragraph))) {
+ double measure = width - rightIndent(text(document, "Built backend")) / 20.0;
+ tightest = Math.min(tightest, measure - widest);
+ }
+ }
+ assertThat(tightest).as("the least room any line, picture and all, is left in Word's measure")
+ .isGreaterThanOrEqualTo(0.99);
+ }
+
+ /** A laid-out line's width as Word sets it: text at its half-point size, pictures as they are. */
+ private static double wordsWidth(ParagraphLine line) {
+ double width = 0;
+ for (var span : line.spans()) {
+ if (span instanceof com.demcha.compose.document.layout.payloads.ParagraphTextSpan text) {
+ double size = text.textStyle().size();
+ width += text.width() * Math.round(size * 2) / 2.0 / size;
+ } else {
+ width += span.width();
+ }
+ }
+ return width;
+ }
+
+ private static byte[] pngBytes() {
+ try (java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream()) {
+ javax.imageio.ImageIO.write(new java.awt.image.BufferedImage(24, 8, java.awt.image.BufferedImage.TYPE_INT_RGB), "png", out);
+ return out.toByteArray();
+ } catch (java.io.IOException failure) {
+ throw new IllegalStateException(failure);
+ }
+ }
+
/**
* The widest line, and the narrowest line with a space and its next line's first word, as
* the page lays them out; a word's width is its own line's, laid out alone.
@@ -153,10 +216,14 @@ private static double widthAlone(String text) {
}
private static List laidOut(double room, String text) {
+ return laidOut(room, p -> p.text(text).textStyle(DocumentTextStyle.DEFAULT.withSize(7.95)));
+ }
+
+ private static List laidOut(double room,
+ java.util.function.Consumer paragraph) {
try (DocumentSession session = GraphCompose.document().pageSize(room + 40, 400)
.margin(DocumentInsets.of(20)).create()) {
- session.pageFlow(page -> page.addParagraph(p -> p.text(text)
- .textStyle(DocumentTextStyle.DEFAULT.withSize(7.95))));
+ session.pageFlow(page -> page.addParagraph(paragraph));
return session.layoutGraph().fragments().stream()
.filter(fragment -> fragment.payload() instanceof ParagraphFragmentPayload)
.flatMap(fragment -> ((ParagraphFragmentPayload) fragment.payload()).lines().stream())
@@ -171,17 +238,21 @@ private static long rightIndent(XWPFParagraph paragraph) {
return indent == null || !indent.isSetRight() ? 0 : DocxTwips.of(indent.getRight());
}
- private static XWPFParagraph paragraph(double size, TextAlign align) throws Exception {
- XWPFDocument document = DocxExports.withLayout(400, 400, 20, page -> page
- .addParagraph(p -> p.text("Platform engineer with ten years of document pipelines.")
- .textStyle(DocumentTextStyle.DEFAULT.withSize(size))
- .align(align)));
- return text(document, "Platform engineer");
+ private static final String SHORT = "Platform engineer with ten years of document pipelines.";
+ private static final String LONG = SHORT + " Built resilient layout engines, template systems and the "
+ + "snapshot-tested libraries that replace brittle production scripts across many teams.";
+
+ /** The right indent of a paragraph of the text given, at a size and an alignment. */
+ private static long indentOf(double size, TextAlign align, String words) throws Exception {
+ try (XWPFDocument document = DocxExports.withLayout(400, 400, 20, page -> page
+ .addParagraph(p -> p.text(words).textStyle(DocumentTextStyle.DEFAULT.withSize(size)).align(align)))) {
+ return rightIndent(text(document, "Platform engineer"));
+ }
}
private static XWPFParagraph text(XWPFDocument document, String start) {
return document.getParagraphs().stream()
- .filter(p -> p.getText().startsWith(start))
+ .filter(p -> p.getText().strip().startsWith(start))
.findFirst().orElseThrow();
}
}