From 2a8d38a3df87cf42dbbe26ef0e749812094760d8 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sat, 3 Oct 2026 19:46:50 +0100
Subject: [PATCH 1/2] fix(docx): give a centred or right-aligned line that
fills its box Word's room
---
CHANGELOG.md | 8 ++
docs/recipes/docx-export.md | 9 +-
.../libreoffice-windows-lines.tsv | 5 +-
.../docx-fidelity/libreoffice-windows.tsv | 2 +-
.../docx-fidelity/word-windows-lines.tsv | 5 +-
.../resources/docx-fidelity/word-windows.tsv | 2 +-
.../semantic/docx/DocxSemanticBackend.java | 93 +++++++++++++++----
.../docx/DocxWordSizeMeasureTest.java | 88 ++++++++++++++++++
8 files changed, 184 insertions(+), 28 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8dad04d20..5750bcfff 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,14 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A centred or right-aligned line that fills its box keeps to one line in Word.** Word sets
+ a size in half points, so a 6.8pt line is set at 7pt, 2.9% wider, and no longer fits a box it
+ fills. A flush-left paragraph was already given that much more measure; a centred or
+ right-aligned one of one line now is too, at both edges or at its left, so the line stays
+ where the page sets it.
+ `VioletGrid`'s "INFORMATION ARCHITECTURE" broke onto two lines in its skill tile, and the
+ tile's text stood 7.5pt low under it. Lines more than 2pt off in Word fall from 2 to none
+ across the corpus, and `VioletGrid`'s median in LibreOffice from 5.2pt to 0.4pt.
- **A table or a row keeps its own margins in DOCX.** Its left margin is now written into its
indent (`w:tblInd`), as a paragraph's is: it was dropped everywhere, and `PaymentsInvoice`'s
bank details stood 36.7pt left of the page's in Word. A table indented in a cell takes that
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 25f65d6e6..209017003 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -309,8 +309,13 @@ of several lines stays a point short of that, unless a line needs the room, so a
broke off by a fraction of a point is not pulled back up. A paragraph of one line is never
narrowed, and has room for a line three hundredths wider than its own: Word sets a face other
than the page's, or emboldens one whose file does not say it is bold, a little wider, and a
-line of one broken in Word is a line more. 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 of one broken in Word is a line more. A centred or right-aligned paragraph of one line
+whose line, as Word sets it and three hundredths wider, does not fit its box is given that
+room: at both edges, half each, or at its left edge, so the line stays where the page sets
+it. In a table cell, where Word draws no text past the cell's left edge, the left indent
+gives no more than it has, and the right edge gives the rest. Any other centred or right-aligned paragraph keeps the page's measure: in one of
+several lines a wider measure takes another word onto a line, and a short line's box already
+has room for it. 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/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv
index b14091dfd..739dd25e9 100644
--- a/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv
+++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv
@@ -1633,8 +1633,9 @@ cv-violet_grid 0:b96067918254 -0.26 uxresearch
cv-violet_grid 0:9058674fd905 -0.21 userinterviews,surveys,
cv-violet_grid 0:0cdd38014d79 -0.22 personas,journeymaps,
cv-violet_grid 0:61e4ba28d469 -0.22 competitiveanalysis
-cv-violet_grid 0:db5c6756c4b6 7.59 sitemaps,userflows,card
-cv-violet_grid 0:6c7ad1834a60 7.58 sorting,contentstrategy
+cv-violet_grid 0:3de6be58cb9f -0.26 informationarchitecture
+cv-violet_grid 0:db5c6756c4b6 -0.21 sitemaps,userflows,card
+cv-violet_grid 0:6c7ad1834a60 -0.22 sorting,contentstrategy
cv-violet_grid 0:187737d59250 -0.26 wireframing
cv-violet_grid 0:2286e5aa328a -0.21 lowtohigh-fidelity
cv-violet_grid 0:5c4b21cb1af8 -0.26 prototyping
diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv
index b94487410..6c64f5844 100644
--- a/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv
+++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv
@@ -25,7 +25,7 @@ cv-slate_orange 1 1 87 86 3.27 3.87 86
cv-teal_pulse 1 1 57 57 0.22 0.64 0
cv-terracotta_rail 1 1 102 102 0.44 0.94 0
cv-timeline_minimal 2 2 89 86 0.45 0.50 0
-cv-violet_grid 1 2 80 76 5.21 21.41 39
+cv-violet_grid 1 2 80 77 0.36 21.41 37
invoice-classic 1 1 56 56 0.17 0.32 0
invoice-classic_long 2 2 166 166 0.22 0.27 0
invoice-consulting 1 1 92 92 0.67 1.01 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 6878c2617..01e5bd24b 100644
--- a/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv
+++ b/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv
@@ -1726,8 +1726,9 @@ cv-violet_grid 0:b96067918254 -0.23 uxresearch
cv-violet_grid 0:9058674fd905 -0.28 userinterviews,surveys,
cv-violet_grid 0:0cdd38014d79 -0.36 personas,journeymaps,
cv-violet_grid 0:61e4ba28d469 -0.28 competitiveanalysis
-cv-violet_grid 0:db5c6756c4b6 7.52 sitemaps,userflows,card
-cv-violet_grid 0:6c7ad1834a60 7.47 sorting,contentstrategy
+cv-violet_grid 0:3de6be58cb9f -0.23 informationarchitecture
+cv-violet_grid 0:db5c6756c4b6 -0.28 sitemaps,userflows,card
+cv-violet_grid 0:6c7ad1834a60 -0.36 sorting,contentstrategy
cv-violet_grid 0:187737d59250 -0.23 wireframing
cv-violet_grid 0:2286e5aa328a -0.28 lowtohigh-fidelity
cv-violet_grid 0:5c4b21cb1af8 -0.23 prototyping
diff --git a/qa/src/test/resources/docx-fidelity/word-windows.tsv b/qa/src/test/resources/docx-fidelity/word-windows.tsv
index df8f19b25..c60657f98 100644
--- a/qa/src/test/resources/docx-fidelity/word-windows.tsv
+++ b/qa/src/test/resources/docx-fidelity/word-windows.tsv
@@ -25,7 +25,7 @@ cv-slate_orange 1 1 87 85 0.53 1.18 0
cv-teal_pulse 1 1 57 57 0.11 0.38 0
cv-terracotta_rail 1 1 102 102 0.42 0.87 0
cv-timeline_minimal 2 2 89 86 0.36 0.48 0
-cv-violet_grid 1 1 80 77 0.22 1.09 2
+cv-violet_grid 1 1 80 78 0.23 0.37 0
invoice-classic 1 1 56 56 0.22 0.36 0
invoice-classic_long 2 2 166 166 0.27 0.34 0
invoice-consulting 1 1 92 92 0.70 1.02 0
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 8566239d9..346575945 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
@@ -4707,25 +4707,85 @@ private boolean letTheLineStandOut(XWPFParagraph para, ParagraphNode node, doubl
* 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.
+ * A paragraph set flush left has its measure moved at its right edge. A centred or
+ * right-aligned one is given room only for a line of its own that Word sets past its box
+ * or within three hundredths of it ({@link #ONE_LINE_FACE_SLACK}), at both edges, half each,
+ * or at its left, so the line stays where the page sets it — in a cell, where Word draws no
+ * text past the left edge, the right edge gives what the left indent cannot:
+ * {@code VioletGrid}'s 6.8pt "INFORMATION ARCHITECTURE", set at 7pt and centred in a tile
+ * it fills, broke onto two lines, and the tile's text stood 7.5pt low under it. A centred
+ * paragraph of several lines is left as it is — a wider measure there takes another word
+ * onto a line Word already breaks elsewhere than the page — and so is a short line, whose
+ * box has room for it: Word sets a centred line in a cell a little off its place when an
+ * indent reaches past the cell's edge. 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)) {
+ boolean flushLeft = node.align() != TextAlign.CENTER && node.align() != TextAlign.RIGHT;
+ if (!Double.isFinite(room) || !flushLeft && layout.lineCount(node) != 1) {
return;
}
- double more = wordsMeasure(node, room) - room;
+ double more = (flushLeft ? wordsMeasure(node, room) : oneLinesMeasure(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)));
+ long twips = Math.round(more * POINT_TO_TWIP);
+ // The edge the text does not lean on moves: a centred line's two, half each.
+ long fromTheLeft = node.align() == TextAlign.RIGHT ? twips
+ : node.align() == TextAlign.CENTER ? twips / 2
+ : 0;
+ long left = twipsOf(indent.isSetLeft() ? indent.getLeft() : null);
+ if (currentCell != null) {
+ // Word draws no text past a cell's left edge (see leftIndentTwips): what the left
+ // indent cannot give, the right one does, and the line moves by that much.
+ fromTheLeft = Math.min(fromTheLeft, Math.max(0, left));
+ }
+ long fromTheRight = twips - fromTheLeft;
+ if (fromTheLeft != 0) {
+ indent.setLeft(BigInteger.valueOf(left - fromTheLeft));
+ }
+ if (fromTheRight != 0) {
+ long right = twipsOf(indent.isSetRight() ? indent.getRight() : null);
+ indent.setRight(BigInteger.valueOf(right - fromTheRight));
+ }
+ }
+
+ /**
+ * The measure a paragraph of one line needs in Word, in points: its box, or its line as wide
+ * as Word sets it and a few hundredths more ({@link #ONE_LINE_FACE_SLACK}), whichever is
+ * wider.
+ */
+ private double oneLinesMeasure(ParagraphNode node, double room) {
+ double set = 0;
+ for (com.demcha.compose.document.layout.payloads.ParagraphLine line : layout.lines(node)) {
+ set = Math.max(set, widthAtWordsSize(line));
+ }
+ return Math.max(room, set * (1 + ONE_LINE_FACE_SLACK));
+ }
+
+ /**
+ * How wide Word sets a line the page laid out: its text grown or shrunk to Word's size, its
+ * tracking, pictures and shapes as the page has them.
+ */
+ private static double widthAtWordsSize(com.demcha.compose.document.layout.payloads.ParagraphLine line) {
+ double set = 0;
+ for (com.demcha.compose.document.layout.payloads.ParagraphSpan span : line.spans()) {
+ if (!(span.width() > 0)) {
+ continue;
+ }
+ 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;
+ } else {
+ set += span.width();
+ }
+ }
+ return set;
}
/** How far inside the page's measure, grown or shrunk as Word sets it, Word's is held, in points. */
@@ -4771,22 +4831,15 @@ private double wordsMeasure(ParagraphNode node, double room) {
for (com.demcha.compose.document.layout.payloads.ParagraphLine line : layout.lines(node)) {
broken++;
double asked = 0;
- double set = 0;
boolean text = false;
for (com.demcha.compose.document.layout.payloads.ParagraphSpan span : line.spans()) {
- 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 (span.width() > 0) {
+ asked += span.width();
+ text |= span instanceof com.demcha.compose.document.layout.payloads.ParagraphTextSpan run
+ && run.textStyle() != null && run.textStyle().size() > 0;
}
}
+ double set = widthAtWordsSize(line);
if (text) {
share = Double.isNaN(share) ? set / asked : Math.max(share, set / asked);
fits = Math.max(fits, set);
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 265a1ce4f..85b478d53 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
@@ -75,6 +75,94 @@ void aCentredLineIsLeftWhereThePageSetsIt() throws Exception {
assertThat(indentOf(7.8, TextAlign.CENTER, LONG)).isZero();
}
+ @Test
+ void aShortCentredLineIsLeftWhereThePageSetsIt() throws Exception {
+ // Its box has room for it as Word sets it: an indent past a cell's edge moves it.
+ assertThat(indentOf(7.8, TextAlign.CENTER, SHORT)).isZero();
+ }
+
+ @Test
+ void aCentredLineThatFillsItsBoxIsGivenRoomAtBothEdgesAlike() throws Exception {
+ // VioletGrid's 6.8pt "INFORMATION ARCHITECTURE" fills its tile; set at 7pt it broke in two.
+ double room = fillingRoom(HEADING, 6.8);
+ try (XWPFDocument document = filledBy(HEADING, 6.8, TextAlign.CENTER, room)) {
+ XWPFParagraph heading = text(document, "INFORMATION");
+ double measure = room - (leftIndent(heading) + rightIndent(heading)) / 20.0;
+
+ assertThat(leftIndent(heading)).as("both edges move").isNegative();
+ assertThat(Math.abs(leftIndent(heading) - rightIndent(heading))).as("alike, so its centre stays")
+ .isLessThanOrEqualTo(1);
+ assertThat(measure).as("the line fits at Word's 7pt").isGreaterThan(widthAlone(HEADING, 6.8) * 7 / 6.8);
+ }
+ }
+
+ @Test
+ void aRightAlignedLineThatFillsItsBoxIsGivenRoomAtItsLeft() throws Exception {
+ double room = fillingRoom(HEADING, 6.8);
+ try (XWPFDocument document = filledBy(HEADING, 6.8, TextAlign.RIGHT, room)) {
+ XWPFParagraph heading = text(document, "INFORMATION");
+
+ assertThat(rightIndent(heading)).as("its edge stays").isZero();
+ assertThat(room - leftIndent(heading) / 20.0).as("the line fits at Word's 7pt")
+ .isGreaterThan(widthAlone(HEADING, 6.8) * 7 / 6.8);
+ }
+ }
+
+ @Test
+ void aRightAlignedLineFillingACellTakesNoTextPastTheCellsLeftEdge() throws Exception {
+ // Word draws no text left of a cell's edge: the room the left indent cannot give comes
+ // from the right.
+ double column = fillingRoom(HEADING, 6.8);
+ try (XWPFDocument document = DocxExports.withLayout(400, 300, 20, page -> page
+ .addRow("Tile", row -> row
+ .columns(DocumentRowColumn.fixed(column), DocumentRowColumn.weight(1))
+ .addParagraph(p -> p.text(HEADING).textStyle(DocumentTextStyle.DEFAULT.withSize(6.8))
+ .align(TextAlign.RIGHT))
+ .addParagraph("Main")))) {
+ var cell = document.getTables().get(0).getRow(0).getCell(0);
+ XWPFParagraph heading = cell.getParagraphs().stream()
+ .filter(p -> p.getText().startsWith("INFORMATION")).findFirst().orElseThrow();
+ long cellWidth = DocxTwips.of(cell.getCTTc().getTcPr().getTcW().getW());
+
+ assertThat(leftIndent(heading)).as("nothing past the cell's left edge").isGreaterThanOrEqualTo(0);
+ assertThat((cellWidth - leftIndent(heading) - rightIndent(heading)) / 20.0)
+ .as("the line still fits at Word's 7pt").isGreaterThan(widthAlone(HEADING, 6.8) * 7 / 6.8);
+ }
+ }
+
+ @Test
+ void aRightAlignedParagraphOfSeveralLinesIsLeftWhereThePageSetsIt() throws Exception {
+ try (XWPFDocument document = DocxExports.withLayout(400, 400, 20, page -> page
+ .addParagraph(p -> p.text(LONG).textStyle(DocumentTextStyle.DEFAULT.withSize(7.8))
+ .align(TextAlign.RIGHT)))) {
+ XWPFParagraph paragraph = text(document, "Platform engineer");
+
+ assertThat(leftIndent(paragraph)).isZero();
+ assertThat(rightIndent(paragraph)).isZero();
+ }
+ }
+
+ private static final String HEADING = "INFORMATION ARCHITECTURE";
+
+ /** A measure the line fills at the page's size, with less than Word's size needs to spare. */
+ private static double fillingRoom(String line, double size) {
+ return widthAlone(line, size) + 0.5;
+ }
+
+ private static XWPFDocument filledBy(String line, double size, TextAlign align, double room) throws Exception {
+ return DocxExports.withLayout(room + 40, 300, 20, page -> page
+ .addParagraph(p -> p.text(line).textStyle(DocumentTextStyle.DEFAULT.withSize(size)).align(align)));
+ }
+
+ private static double widthAlone(String text, double size) {
+ return laidOut(500, p -> p.text(text).textStyle(DocumentTextStyle.DEFAULT.withSize(size))).get(0).width();
+ }
+
+ private static long leftIndent(XWPFParagraph paragraph) {
+ CTInd indent = paragraph.getCTP().getPPr() == null ? null : paragraph.getCTP().getPPr().getInd();
+ return indent == null || !indent.isSetLeft() ? 0 : DocxTwips.of(indent.getLeft());
+ }
+
@Test
void theMeasureIsTheOneTheLineThatGrowsMostNeeds() throws Exception {
// EngineeringResume's projects: a 7.35pt title, set at 7.5, fills most of the first
From 018c84c976631ecd46800b31bac78752267dcef5 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Sat, 3 Oct 2026 20:01:30 +0100
Subject: [PATCH 2/2] fix(docx): leave a picture's line its width, floor a
hanging line in a cell, and take the Linux LibreOffice baseline from CI
---
CHANGELOG.md | 6 +-
docs/recipes/docx-export.md | 9 ++-
.../docx-fidelity/libreoffice-linux-lines.tsv | 5 +-
.../docx-fidelity/libreoffice-linux.tsv | 2 +-
.../semantic/docx/DocxSemanticBackend.java | 18 +++--
.../docx/DocxWordSizeMeasureTest.java | 79 ++++++++++++++-----
6 files changed, 87 insertions(+), 32 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5750bcfff..ea28ddc2a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,10 +12,12 @@ follow semantic versioning; release dates are ISO 8601.
a size in half points, so a 6.8pt line is set at 7pt, 2.9% wider, and no longer fits a box it
fills. A flush-left paragraph was already given that much more measure; a centred or
right-aligned one of one line now is too, at both edges or at its left, so the line stays
- where the page sets it.
+ where the page sets it. In a table cell the left edge gives no more than its indent, since
+ Word draws no text past a cell's left edge, and a line short of that moves by the rest.
`VioletGrid`'s "INFORMATION ARCHITECTURE" broke onto two lines in its skill tile, and the
tile's text stood 7.5pt low under it. Lines more than 2pt off in Word fall from 2 to none
- across the corpus, and `VioletGrid`'s median in LibreOffice from 5.2pt to 0.4pt.
+ across the corpus. In LibreOffice, which still sets `VioletGrid` on two pages, its median
+ falls from 5.2pt to 0.4pt.
- **A table or a row keeps its own margins in DOCX.** Its left margin is now written into its
indent (`w:tblInd`), as a paragraph's is: it was dropped everywhere, and `PaymentsInvoice`'s
bank details stood 36.7pt left of the page's in Word. A table indented in a cell takes that
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 209017003..4d035fbe2 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -313,10 +313,11 @@ line of one broken in Word is a line more. A centred or right-aligned paragraph
whose line, as Word sets it and three hundredths wider, does not fit its box is given that
room: at both edges, half each, or at its left edge, so the line stays where the page sets
it. In a table cell, where Word draws no text past the cell's left edge, the left indent
-gives no more than it has, and the right edge gives the rest. Any other centred or right-aligned paragraph keeps the page's measure: in one of
-several lines a wider measure takes another word onto a line, and a short line's box already
-has room for it. 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.
+gives no more than it has, and the right edge gives the rest, the line moving by that much.
+Any other centred or right-aligned paragraph keeps the page's measure: in one of several
+lines a wider measure takes another word onto a line, and a short line's box already has
+room for it. 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/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv
index 39a9b350b..4978b614e 100644
--- a/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv
+++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv
@@ -1727,8 +1727,9 @@ cv-violet_grid 0:b96067918254 -0.26 uxresearch
cv-violet_grid 0:9058674fd905 -0.21 userinterviews,surveys,
cv-violet_grid 0:0cdd38014d79 -0.22 personas,journeymaps,
cv-violet_grid 0:61e4ba28d469 -0.22 competitiveanalysis
-cv-violet_grid 0:db5c6756c4b6 7.59 sitemaps,userflows,card
-cv-violet_grid 0:6c7ad1834a60 7.58 sorting,contentstrategy
+cv-violet_grid 0:3de6be58cb9f -0.26 informationarchitecture
+cv-violet_grid 0:db5c6756c4b6 -0.21 sitemaps,userflows,card
+cv-violet_grid 0:6c7ad1834a60 -0.22 sorting,contentstrategy
cv-violet_grid 0:187737d59250 -0.26 wireframing
cv-violet_grid 0:2286e5aa328a -0.21 lowtohigh-fidelity
cv-violet_grid 0:5c4b21cb1af8 -0.26 prototyping
diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv
index 3f048ec40..313c98f10 100644
--- a/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv
+++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv
@@ -25,7 +25,7 @@ cv-slate_orange 1 1 87 86 3.27 3.87 86
cv-teal_pulse 1 1 57 57 0.22 0.64 0
cv-terracotta_rail 1 1 102 102 0.44 0.94 0
cv-timeline_minimal 2 2 89 86 0.45 0.50 0
-cv-violet_grid 1 2 80 76 5.21 21.41 39
+cv-violet_grid 1 2 80 77 0.36 21.41 37
invoice-classic 1 1 56 56 0.12 0.30 0
invoice-classic_long 2 2 166 166 0.20 0.27 0
invoice-consulting 1 1 92 92 0.67 0.96 0
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 346575945..aa129343a 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
@@ -4716,8 +4716,8 @@ private boolean letTheLineStandOut(XWPFParagraph para, ParagraphNode node, doubl
* it fills, broke onto two lines, and the tile's text stood 7.5pt low under it. A centred
* paragraph of several lines is left as it is — a wider measure there takes another word
* onto a line Word already breaks elsewhere than the page — and so is a short line, whose
- * box has room for it: Word sets a centred line in a cell a little off its place when an
- * indent reaches past the cell's edge. A list's items, a line pair, text over the flow and
+ * box has room for it: measured, {@code MerchantInvoice}'s centred amounts, their cells'
+ * indents taken past the cells' edges, stood 0.12pt off their place. 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
@@ -4741,8 +4741,10 @@ private void measureAtWordsSize(XWPFParagraph para, ParagraphNode node, double r
long left = twipsOf(indent.isSetLeft() ? indent.getLeft() : null);
if (currentCell != null) {
// Word draws no text past a cell's left edge (see leftIndentTwips): what the left
- // indent cannot give, the right one does, and the line moves by that much.
- fromTheLeft = Math.min(fromTheLeft, Math.max(0, left));
+ // indent cannot give, the right one does, and the line moves by that much. A hanging
+ // first line already starts that much further left.
+ long hanging = twipsOf(indent.isSetHanging() ? indent.getHanging() : null);
+ fromTheLeft = Math.min(fromTheLeft, Math.max(0, left - hanging));
}
long fromTheRight = twips - fromTheLeft;
if (fromTheLeft != 0) {
@@ -4762,7 +4764,13 @@ private void measureAtWordsSize(XWPFParagraph para, ParagraphNode node, double r
private double oneLinesMeasure(ParagraphNode node, double room) {
double set = 0;
for (com.demcha.compose.document.layout.payloads.ParagraphLine line : layout.lines(node)) {
- set = Math.max(set, widthAtWordsSize(line));
+ // A line of pictures alone is set at their size, in Word as on the page.
+ boolean text = line.spans().stream().anyMatch(span -> span.width() > 0
+ && span instanceof com.demcha.compose.document.layout.payloads.ParagraphTextSpan run
+ && run.textStyle() != null && run.textStyle().size() > 0);
+ if (text) {
+ set = Math.max(set, widthAtWordsSize(line));
+ }
}
return Math.max(room, set * (1 + ONE_LINE_FACE_SLACK));
}
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 85b478d53..81536eae2 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
@@ -77,8 +77,15 @@ void aCentredLineIsLeftWhereThePageSetsIt() throws Exception {
@Test
void aShortCentredLineIsLeftWhereThePageSetsIt() throws Exception {
- // Its box has room for it as Word sets it: an indent past a cell's edge moves it.
- assertThat(indentOf(7.8, TextAlign.CENTER, SHORT)).isZero();
+ // Its box has room for it as Word sets it, so neither edge moves.
+ try (XWPFDocument document = DocxExports.withLayout(400, 400, 20, page -> page
+ .addParagraph(p -> p.text(SHORT).textStyle(DocumentTextStyle.DEFAULT.withSize(7.8))
+ .align(TextAlign.CENTER)))) {
+ XWPFParagraph paragraph = text(document, "Platform engineer");
+
+ assertThat(leftIndent(paragraph)).isZero();
+ assertThat(rightIndent(paragraph)).isZero();
+ }
}
@Test
@@ -142,25 +149,40 @@ void aRightAlignedParagraphOfSeveralLinesIsLeftWhereThePageSetsIt() throws Excep
}
}
- private static final String HEADING = "INFORMATION ARCHITECTURE";
-
- /** A measure the line fills at the page's size, with less than Word's size needs to spare. */
- private static double fillingRoom(String line, double size) {
- return widthAlone(line, size) + 0.5;
- }
-
- private static XWPFDocument filledBy(String line, double size, TextAlign align, double room) throws Exception {
- return DocxExports.withLayout(room + 40, 300, 20, page -> page
- .addParagraph(p -> p.text(line).textStyle(DocumentTextStyle.DEFAULT.withSize(size)).align(align)));
- }
+ @Test
+ void aCellsLeftIndentGivesWhatItHasAndTheRightTheRest() throws Exception {
+ // Padded 2.5pt, the paragraph keeps half a point of indent past the editor's slack.
+ double column = fillingRoom(HEADING, 6.8) + 2.5;
+ try (XWPFDocument document = DocxExports.withLayout(400, 300, 20, page -> page
+ .addRow("Tile", row -> row
+ .columns(DocumentRowColumn.fixed(column), DocumentRowColumn.weight(1))
+ .addParagraph(p -> p.text(HEADING).textStyle(DocumentTextStyle.DEFAULT.withSize(6.8))
+ .padding(new DocumentInsets(0, 0, 0, 2.5)).align(TextAlign.RIGHT))
+ .addParagraph("Main")))) {
+ var cell = document.getTables().get(0).getRow(0).getCell(0);
+ XWPFParagraph heading = cell.getParagraphs().stream()
+ .filter(p -> p.getText().startsWith("INFORMATION")).findFirst().orElseThrow();
+ long cellWidth = DocxTwips.of(cell.getCTTc().getTcPr().getTcW().getW());
- private static double widthAlone(String text, double size) {
- return laidOut(500, p -> p.text(text).textStyle(DocumentTextStyle.DEFAULT.withSize(size))).get(0).width();
+ assertThat(leftIndent(heading)).as("all the half point it had, and no more").isZero();
+ assertThat(rightIndent(heading)).as("the rest from the right").isNegative();
+ assertThat((cellWidth - leftIndent(heading) - rightIndent(heading)) / 20.0)
+ .as("the line still fits at Word's 7pt").isGreaterThan(widthAlone(HEADING, 6.8) * 7 / 6.8);
+ }
}
- private static long leftIndent(XWPFParagraph paragraph) {
- CTInd indent = paragraph.getCTP().getPPr() == null ? null : paragraph.getCTP().getPPr().getInd();
- return indent == null || !indent.isSetLeft() ? 0 : DocxTwips.of(indent.getLeft());
+ @Test
+ void aCentredLineOfAPictureAloneIsLeftAsItIs() throws Exception {
+ // A picture is written at its size and takes the same room in Word.
+ try (XWPFDocument document = DocxExports.withLayout(150.5 + 40, 300, 20, page -> page
+ .addParagraph(p -> p.rich(rich -> rich.image(
+ com.demcha.compose.document.image.DocumentImageData.fromBytes(pngBytes()), 150, 20))
+ .align(TextAlign.CENTER)))) {
+ XWPFParagraph picture = document.getParagraphs().get(0);
+
+ assertThat(leftIndent(picture)).isZero();
+ assertThat(rightIndent(picture)).isZero();
+ }
}
@Test
@@ -342,6 +364,27 @@ private static long rightIndent(XWPFParagraph paragraph) {
return indent == null || !indent.isSetRight() ? 0 : DocxTwips.of(indent.getRight());
}
+ private static final String HEADING = "INFORMATION ARCHITECTURE";
+
+ /** A measure the line fills at the page's size, with less than Word's size needs to spare. */
+ private static double fillingRoom(String line, double size) {
+ return widthAlone(line, size) + 0.5;
+ }
+
+ private static XWPFDocument filledBy(String line, double size, TextAlign align, double room) throws Exception {
+ return DocxExports.withLayout(room + 40, 300, 20, page -> page
+ .addParagraph(p -> p.text(line).textStyle(DocumentTextStyle.DEFAULT.withSize(size)).align(align)));
+ }
+
+ private static double widthAlone(String text, double size) {
+ return laidOut(500, p -> p.text(text).textStyle(DocumentTextStyle.DEFAULT.withSize(size))).get(0).width();
+ }
+
+ private static long leftIndent(XWPFParagraph paragraph) {
+ CTInd indent = paragraph.getCTP().getPPr() == null ? null : paragraph.getCTP().getPPr().getInd();
+ return indent == null || !indent.isSetLeft() ? 0 : DocxTwips.of(indent.getLeft());
+ }
+
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.";