From 227b6b494d8ba235418c782553eecda85bb3c8d0 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Fri, 2 Oct 2026 12:31:17 +0100
Subject: [PATCH 1/2] fix(docx): keep a row's fixed columns at their widths
when they leave the rest of the row empty
---
CHANGELOG.md | 8 ++++
docs/recipes/docx-export.md | 2 +-
.../libreoffice-windows-lines.tsv | 22 ++++++++---
.../docx-fidelity/libreoffice-windows.tsv | 4 +-
.../docx-fidelity/word-windows-lines.tsv | 22 ++++++++---
.../resources/docx-fidelity/word-windows.tsv | 4 +-
.../semantic/docx/DocxSemanticBackend.java | 38 ++++++++++++++++++-
.../semantic/docx/DocxEditorSlackTest.java | 3 +-
.../semantic/docx/DocxRowLayoutTest.java | 15 ++++++++
9 files changed, 100 insertions(+), 18 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bfe39695e..0530a0be8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,14 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A row's fixed columns keep their widths in DOCX when they add up to less than the row.**
+ The last column ran to the row's edge: `EditorialProposal`'s deliverables, a 194pt column in a
+ 535pt band, came out 272pt wide, and Word set "Responsive design for desktop, tablet & mobile"
+ on one line where the page wraps it, and every item under it a line higher. The last column
+ now keeps its stated width, and the table is no wider than its columns. In Word,
+ `EditorialProposal` finds 128 of its 132 lines where it found 120, and those more than 2pt off
+ fall from 22 to 19; `PaymentsInvoice` finds 104 of 104, none past 2pt, where it found 100.
+
- **`MidnightNavy`'s sidebar stands where the page sets it in Word.** Three causes, each its own:
- Its monogram centres two initials in a 76pt ring, two layers of one shape container. Only a
container of one layer was set in from its outline, so the initials were written from the
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index ae7d3c722..8a3c49a8d 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -74,7 +74,7 @@ creation date is real metadata.
| Inline chips (`inlineCode(...)`, `inlineChip(...)`, `highlight(...)`) | The chip's fill becomes the run's own `w:shd`, in a paragraph and in a list item alike. Its shape does not travel — see "What a chip keeps and loses" below |
| Images | Embedded pictures at the node's declared size |
| Links and anchors | A `linkTarget` becomes a `w:hyperlink` — a relationship for an address, `w:anchor` for one of the document's own anchors — and a run's own link wins over the paragraph's — in a list item as much as in a paragraph. An `anchor(...)` becomes a bookmark wrapping that paragraph's text, named as Word requires; on a section, container, table or image it wraps everything the block wrote, from the start of its first paragraph to the end of its last, so a link to a block lands on its first line. A `bookmark(...)` outline level becomes Word's own `HeadingN` style, which is what puts the paragraph in the Navigation Pane, the outline view and a generated table of contents. The style states the outline level and nothing else, so the paragraph keeps its own formatting. The role comes from what the document declared, never from how big the text is |
-| Rows | A one-row table spanning the content width, so editors keep the side-by-side layout. The row's slots become the column grid when they are weights, an even split or fixed columns; the gap and the row's padding ride in the neighbouring column and come back out as that cell's margin; a cell holds whatever its child is, written as it is anywhere else. The row's `verticalAlign` is every cell's `w:vAlign`, so a child shorter than the row sits at its middle or bottom as on the page — a table of contents' leader on its entry's baseline. The row is kept whole across a page break, as the layout keeps it. A row is held at least as tall as the page makes it inside a painted panel, and anywhere its tallest child is a drawing Word holds nothing of in its cell — a badge beside a heading. A section or other container in any cell — a row's, a table's, a panel's — that pulls its first line up with a negative top edge writes that one-line paragraph as much shorter, its text seated where the page sets it — no more than the room above its letters, as Word draws an exact line's text only inside the line |
+| Rows | A one-row table spanning the content width, so editors keep the side-by-side layout. The row's slots become the column grid when they are weights, an even split or fixed columns; fixed columns that add up to less than the row leave the rest of it empty, the last one at its own width and the table no wider than its columns, so its text wraps where the page wraps it; the gap and the row's padding ride in the neighbouring column and come back out as that cell's margin; a cell holds whatever its child is, written as it is anywhere else. The row's `verticalAlign` is every cell's `w:vAlign`, so a child shorter than the row sits at its middle or bottom as on the page — a table of contents' leader on its entry's baseline. The row is kept whole across a page break, as the layout keeps it. A row is held at least as tall as the page makes it inside a painted panel, and anywhere its tallest child is a drawing Word holds nothing of in its cell — a badge beside a heading. A section or other container in any cell — a row's, a table's, a panel's — that pulls its first line up with a negative top edge writes that one-line paragraph as much shorter, its text seated where the page sets it — no more than the room above its letters, as Word draws an exact line's text only inside the line |
| Sections / containers | Children written in order. A timeline with its markers on the rail (`markerOnRail()`) lays each entry's body out in the header row's content column, below the row; the body is written in the flow, indented to that column where the page puts it. A container with a fill, per-side borders or a uniform stroke is a one-cell table carrying them, its padding as the cell's margins, so a card keeps its panel — see "What a panel keeps and loses" below. A `keepTogether()` or `keepWithNext()` block the layout placed on one page stays on one page in Word too (`w:keepLines` + `w:keepNext`, and a row that may not split for a panel) |
| Spacers | An empty paragraph a tenth of a point tall; the spacer's height is the space above the next block, or below this paragraph when a table follows |
| Page breaks | Explicit Word page breaks |
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 ff334efe1..b144ecc91 100644
--- a/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv
+++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv
@@ -3061,7 +3061,9 @@ invoice-payments 0:1a7b308d8df6 0.24 quantity
invoice-payments 0:d1062d072acd 0.24 unitprice
invoice-payments 0:7c512b48ba8a 0.24 vat
invoice-payments 0:2da123241332 0.24 amount(gbp)
-invoice-payments 0:3ab6c1099bcc 5.43 subscriptionbilling
+invoice-payments 0:3ab6c1099bcc 0.43 subscriptionbilling
+invoice-payments 0:c8e69cd6e074 0.57 subscriptionmanagementandrecurri
+invoice-payments 0:0c95c7ece1ce 0.41 billing
invoice-payments 0:96d6b62c061d 0.40 £250.00
invoice-payments 0:96d6b62c061d#1 0.41 £250.00
invoice-payments 0:dc934fe30a94 0.40 20%
@@ -3070,7 +3072,9 @@ invoice-payments 0:dc934fe30a94#2 0.42 20%
invoice-payments 0:dc934fe30a94#3 0.41 20%
invoice-payments 0:dc934fe30a94#4 0.40 20%
invoice-payments 0:dc934fe30a94#5 0.59 20%
-invoice-payments 0:0a9acaea6477 5.43 paymentprocessing
+invoice-payments 0:0a9acaea6477 0.43 paymentprocessing
+invoice-payments 0:b8a3c21545b4 0.57 onlinecardpaymentsprocessing
+invoice-payments 0:b42561a38a06 0.41 (volumetier)
invoice-payments 0:351704ebff51 0.40 £1,180.00
invoice-payments 0:351704ebff51#1 0.41 £1,180.00
invoice-payments 0:b3bc34f90a55 0.42 fraudscreening
@@ -3618,7 +3622,11 @@ proposal-editorial 0:e8228bfeba0e 0.15 foundationforfuture
proposal-editorial 0:1fa35c7a1402 -0.85 growth.
proposal-editorial 0:bc981abcdf59 -1.33 scopeofwork
proposal-editorial 0:28450edd9a4f -0.25 discovery&research
+proposal-editorial 0:ce9012b74aa2 0.00 stakeholderinterviews,brandaudit
+proposal-editorial 0:730c3e278933 -0.28 audienceinsightstoinformstrategy
proposal-editorial 0:12bff3036b66 -0.16 visualidentityrefinement
+proposal-editorial 0:937ad523a90c 0.09 refinelogosystem,colorpalette,ty
+proposal-editorial 0:9b232e66f46c -0.19 guidelinesforacohesive,modernide
proposal-editorial 0:fc5134bf5fd1 -0.07 websiteux/uidesign
proposal-editorial 0:5d58b01c7096 0.18 designaresponsive,accessibility-
proposal-editorial 0:5d6068f6a879 -0.10 navigationandclearconversionpath
@@ -3626,6 +3634,8 @@ proposal-editorial 0:32001e20b2ed 0.02 contentstructure&messaging
proposal-editorial 0:b3c948ec3e36 0.27 definesitearchitecture,keymessag
proposal-editorial 0:231428fde7aa -0.01 communicatevalueandbuildtrust.
proposal-editorial 0:8c7703bb6fb7 0.11 launchsupport
+proposal-editorial 0:34f9fe468474 0.36 developerhandoff,qasupport,train
+proposal-editorial 0:2b4cf15f6f16 0.08 toensureasmoothlaunch.
proposal-editorial 0:026890c58de9 0.01 northlinestudio|northlinestudio.
proposal-editorial 1:e35c32bb5ed8 -46.41 deliverables
proposal-editorial 1:23eb79a19b39 -45.38 brandstrategysummary
@@ -3633,9 +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:8b712e7e93bf -57.80 contentstrategy&pagetemplates
-proposal-editorial 1:e9bf06d9b130 -57.80 developer-readyassets&specificat
-proposal-editorial 1:e93e7ba95ceb -57.80 launchchecklist&trainingsession
+proposal-editorial 1:a579d6d1c12e -45.48 responsivedesignfordesktop,table
+proposal-editorial 1:d524c1a0811d -45.13 mobile
+proposal-editorial 1:8b712e7e93bf -44.40 contentstrategy&pagetemplates
+proposal-editorial 1:e9bf06d9b130 -44.40 developer-readyassets&specificat
+proposal-editorial 1:e93e7ba95ceb -44.40 launchchecklist&trainingsession
proposal-editorial 1:94d192b3a326 -46.32 timeline
proposal-editorial 1:02195b8e9896 -45.06 phase
proposal-editorial 1:c51faa148557 -45.06 focus
diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv
index 2e8f8ded2..364e41a97 100644
--- a/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv
+++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv
@@ -37,7 +37,7 @@ invoice-metered 1 1 99 99 0.25 0.69 0
invoice-modern 1 1 54 54 0.32 0.52 0
invoice-modern_long 2 2 162 162 0.35 0.52 0
invoice-obsidian 1 1 81 81 0.29 0.84 0
-invoice-payments 1 1 104 100 0.41 0.91 2
+invoice-payments 1 1 104 104 0.41 0.90 0
invoice-platform 1 1 111 111 0.27 0.43 0
invoice-subscription 1 1 81 81 0.42 0.73 0
invoice-workspace 1 1 90 90 0.35 1.17 0
@@ -56,7 +56,7 @@ letter-nordic_clean 1 1 15 15 0.16 0.38 0
letter-panel 1 1 13 13 0.15 0.21 0
letter-sidebar_portrait 1 1 12 12 0.43 0.51 0
letter-timeline_minimal 1 1 15 15 1.32 1.52 0
-proposal-editorial 2 2 132 119 43.27 45.18 68
+proposal-editorial 2 2 132 127 43.26 45.15 70
proposal-indigo 1 1 72 70 0.57 27.88 16
proposal-modern 1 1 44 44 0.41 0.75 0
proposal-northline 2 2 132 132 0.38 1.62 6
diff --git a/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv b/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv
index 81fef0e11..801f21357 100644
--- a/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv
+++ b/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv
@@ -3159,7 +3159,9 @@ invoice-payments 0:1a7b308d8df6 0.33 quantity
invoice-payments 0:d1062d072acd 0.33 unitprice
invoice-payments 0:7c512b48ba8a 0.33 vat
invoice-payments 0:2da123241332 0.33 amount(gbp)
-invoice-payments 0:3ab6c1099bcc 5.47 subscriptionbilling
+invoice-payments 0:3ab6c1099bcc 0.55 subscriptionbilling
+invoice-payments 0:c8e69cd6e074 0.72 subscriptionmanagementandrecurri
+invoice-payments 0:0c95c7ece1ce 0.52 billing
invoice-payments 0:96d6b62c061d 0.54 £250.00
invoice-payments 0:96d6b62c061d#1 0.50 £250.00
invoice-payments 0:dc934fe30a94 0.54 20%
@@ -3168,7 +3170,9 @@ invoice-payments 0:dc934fe30a94#2 0.51 20%
invoice-payments 0:dc934fe30a94#3 0.51 20%
invoice-payments 0:dc934fe30a94#4 0.51 20%
invoice-payments 0:dc934fe30a94#5 0.62 20%
-invoice-payments 0:0a9acaea6477 5.52 paymentprocessing
+invoice-payments 0:0a9acaea6477 0.48 paymentprocessing
+invoice-payments 0:b8a3c21545b4 0.64 onlinecardpaymentsprocessing
+invoice-payments 0:b42561a38a06 0.56 (volumetier)
invoice-payments 0:351704ebff51 0.47 £1,180.00
invoice-payments 0:351704ebff51#1 0.54 £1,180.00
invoice-payments 0:b3bc34f90a55 0.48 fraudscreening
@@ -3716,7 +3720,11 @@ proposal-editorial 0:e8228bfeba0e 0.33 foundationforfuture
proposal-editorial 0:1fa35c7a1402 -0.68 growth.
proposal-editorial 0:bc981abcdf59 0.10 scopeofwork
proposal-editorial 0:28450edd9a4f -0.19 discovery&research
+proposal-editorial 0:ce9012b74aa2 0.14 stakeholderinterviews,brandaudit
+proposal-editorial 0:730c3e278933 -0.07 audienceinsightstoinformstrategy
proposal-editorial 0:12bff3036b66 0.00 visualidentityrefinement
+proposal-editorial 0:937ad523a90c 0.21 refinelogosystem,colorpalette,ty
+proposal-editorial 0:9b232e66f46c -0.03 guidelinesforacohesive,modernide
proposal-editorial 0:fc5134bf5fd1 0.04 websiteux/uidesign
proposal-editorial 0:5d58b01c7096 0.37 designaresponsive,accessibility-
proposal-editorial 0:5d6068f6a879 0.01 navigationandclearconversionpath
@@ -3724,6 +3732,8 @@ proposal-editorial 0:32001e20b2ed 0.08 contentstructure&messaging
proposal-editorial 0:b3c948ec3e36 0.41 definesitearchitecture,keymessag
proposal-editorial 0:231428fde7aa 0.17 communicatevalueandbuildtrust.
proposal-editorial 0:8c7703bb6fb7 0.24 launchsupport
+proposal-editorial 0:34f9fe468474 0.45 developerhandoff,qasupport,train
+proposal-editorial 0:2b4cf15f6f16 0.21 toensureasmoothlaunch.
proposal-editorial 0:026890c58de9 0.12 northlinestudio|northlinestudio.
proposal-editorial 1:5205c2a261bc 0.41 northline
proposal-editorial 1:da0daaba4b15 0.32 studio
@@ -3734,9 +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:8b712e7e93bf -12.28 contentstrategy&pagetemplates
-proposal-editorial 1:e9bf06d9b130 -12.32 developer-readyassets&specificat
-proposal-editorial 1:e93e7ba95ceb -12.36 launchchecklist&trainingsession
+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:94d192b3a326 0.47 timeline
proposal-editorial 1:02195b8e9896 0.50 phase
proposal-editorial 1:c51faa148557 0.50 focus
diff --git a/qa/src/test/resources/docx-fidelity/word-windows.tsv b/qa/src/test/resources/docx-fidelity/word-windows.tsv
index fe09d08a7..1775a22fb 100644
--- a/qa/src/test/resources/docx-fidelity/word-windows.tsv
+++ b/qa/src/test/resources/docx-fidelity/word-windows.tsv
@@ -37,7 +37,7 @@ invoice-metered 1 1 99 99 0.37 0.77 0
invoice-modern 1 1 54 54 0.31 0.57 0
invoice-modern_long 2 2 162 162 0.44 0.60 0
invoice-obsidian 1 1 81 81 0.26 0.62 0
-invoice-payments 1 1 104 100 0.50 1.10 2
+invoice-payments 1 1 104 104 0.51 1.02 0
invoice-platform 1 1 111 111 0.19 0.41 0
invoice-subscription 1 1 81 81 0.47 0.64 0
invoice-workspace 1 1 90 90 0.44 1.26 0
@@ -56,7 +56,7 @@ letter-nordic_clean 1 1 15 15 0.16 0.35 0
letter-panel 1 1 13 13 0.17 0.25 0
letter-sidebar_portrait 1 1 12 12 0.41 0.52 0
letter-timeline_minimal 1 1 15 15 1.30 1.63 0
-proposal-editorial 2 2 132 120 0.58 2.20 22
+proposal-editorial 2 2 132 128 0.50 2.09 19
proposal-indigo 1 1 72 70 0.43 27.75 16
proposal-modern 1 1 44 44 0.35 0.73 0
proposal-northline 2 2 132 132 0.36 1.75 6
diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxSemanticBackend.java
index 57e65468b..e7826e703 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
@@ -8882,8 +8882,9 @@ private RowGeometry applyRowGeometry(XWPFTable table, RowNode node, double hang)
// The layout placed each child, so every way a row can divide — the two that
// measure their children included — is already answered.
List columns = new ArrayList<>(withRowEditorSlack(node, placedColumns(node, starts)));
+ holdTheLastFixedColumn(node, columns);
double taken = takeHang(node, columns, hang);
- setTableWidth(table, starts[0] - taken);
+ setTableWidth(table, Math.min(starts[0], widthOf(columns) + taken) - taken);
writeRowColumns(table, columns);
return new RowGeometry(true, taken);
}
@@ -8899,11 +8900,44 @@ private RowGeometry applyRowGeometry(XWPFTable table, RowNode node, double hang)
}
List columns = new ArrayList<>(statedColumns(node, slots));
double taken = takeHang(node, columns, hang);
- setTableWidth(table, available - taken);
+ setTableWidth(table, Math.min(available, widthOf(columns) + taken) - taken);
writeRowColumns(table, columns);
return new RowGeometry(false, taken);
}
+ /**
+ * Holds a row's last column to its fixed width, where the row states one, rather than to
+ * the row's edge.
+ *
+ * Fixed columns that add up to less than the row leave the rest of it empty, and the
+ * last column's text wraps at its own width: {@code EditorialProposal}'s deliverables, a
+ * column of 194pt in a band of 535, wrapped "Responsive design for desktop, tablet &
+ * mobile" onto two lines. Run to the row's edge, the cell was 272pt wide, and Word set the
+ * item on one line and every item under it a line higher.
+ */
+ private static void holdTheLastFixedColumn(RowNode node, List columns) {
+ List specs = node.columns();
+ if (columns.isEmpty() || specs.size() != columns.size()
+ || specs.get(specs.size() - 1).type() != DocumentRowColumn.Type.FIXED) {
+ return;
+ }
+ int last = columns.size() - 1;
+ CellColumn column = columns.get(last);
+ double held = column.leading() + specs.get(last).value() + column.trailing();
+ if (held < column.width() - 0.01) {
+ columns.set(last, new CellColumn(held, column.leading(), column.trailing()));
+ }
+ }
+
+ /** The width a row's columns take together, in points. */
+ private static double widthOf(List columns) {
+ double width = 0;
+ for (CellColumn column : columns) {
+ width += column.width();
+ }
+ return width;
+ }
+
/**
* How a row's carrier was divided.
*
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxEditorSlackTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxEditorSlackTest.java
index fe7837d98..10920a69c 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxEditorSlackTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxEditorSlackTest.java
@@ -106,7 +106,8 @@ void aRowWithNoWeightColumnHasNoPointToGiveAndIsWrittenAsPlaced() throws Excepti
.addParagraph("Label").addParagraph("Value"));
assertThat(exported.gridTwips(0)).isEqualTo(exported.placedTwips(0));
- assertThat(exported.gridTwips(1)).isEqualTo(exported.placedTwips(1));
+ assertThat(exported.gridTwips(1)).as("the fixed column at its own width, the rest of the row empty")
+ .isEqualTo(80 * 20L);
}
@Test
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRowLayoutTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRowLayoutTest.java
index cd4a0433b..128916853 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRowLayoutTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRowLayoutTest.java
@@ -148,6 +148,21 @@ void aFixedColumnTakesItsWidthAndAWeightTakesTheRest() throws Exception {
assertThat(gridTwips(table)).containsExactly(2000L, 5200L);
}
+ @Test
+ void fixedColumnsNarrowerThanTheRowLeaveTheRestOfItEmpty() throws Exception {
+ // The last column wraps its text at its own width, laid out or not; the rest of the
+ // row is empty, and the table is no wider than its columns.
+ java.util.function.Consumer row = page -> page.addRow(r -> r
+ .columns(DocumentRowColumn.fixed(100), DocumentRowColumn.fixed(80))
+ .addParagraph(p -> p.text("Label"))
+ .addParagraph(p -> p.text("Value")));
+
+ for (XWPFTable table : List.of(onlyTable(row), measuredTable(row))) {
+ assertThat(gridTwips(table)).containsExactly(2000L, 1600L);
+ assertThat(twips(table.getCTTbl().getTblPr().getTblW().getW())).isEqualTo(3600L);
+ }
+ }
+
@Test
void theRowsPaddingRidesInTheOuterColumnsAndComesBackOutAsAMargin() throws Exception {
// 12pt each side leaves 336 to halve. The padding is part of the table, which
From 664b08c623438e4250d8f702aa10e20b462a67bd Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Fri, 2 Oct 2026 13:51:50 +0100
Subject: [PATCH 2/2] fix(docx): hold a last fixed column without its child's
left margin, and pin the row's padding and full-width tables
---
CHANGELOG.md | 6 ++--
docs/recipes/docx-export.md | 2 +-
.../semantic/docx/DocxSemanticBackend.java | 10 ++++--
.../semantic/docx/DocxEditorSlackTest.java | 5 ++-
.../semantic/docx/DocxRowLayoutTest.java | 33 ++++++++++++++++++-
5 files changed, 48 insertions(+), 8 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0530a0be8..6c70ee8c2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,8 +11,10 @@ follow semantic versioning; release dates are ISO 8601.
- **A row's fixed columns keep their widths in DOCX when they add up to less than the row.**
The last column ran to the row's edge: `EditorialProposal`'s deliverables, a 194pt column in a
535pt band, came out 272pt wide, and Word set "Responsive design for desktop, tablet & mobile"
- on one line where the page wraps it, and every item under it a line higher. The last column
- now keeps its stated width, and the table is no wider than its columns. In Word,
+ on one line where the page wraps it, and every item under it a line higher. The scope of work's
+ descriptions and `PaymentsInvoice`'s item text, both the last of a row's fixed columns, wrapped
+ later than the page the same way. The last column now keeps its stated width, and the table is
+ no wider than its columns. In Word,
`EditorialProposal` finds 128 of its 132 lines where it found 120, and those more than 2pt off
fall from 22 to 19; `PaymentsInvoice` finds 104 of 104, none past 2pt, where it found 100.
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index 8a3c49a8d..f682332dc 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -74,7 +74,7 @@ creation date is real metadata.
| Inline chips (`inlineCode(...)`, `inlineChip(...)`, `highlight(...)`) | The chip's fill becomes the run's own `w:shd`, in a paragraph and in a list item alike. Its shape does not travel — see "What a chip keeps and loses" below |
| Images | Embedded pictures at the node's declared size |
| Links and anchors | A `linkTarget` becomes a `w:hyperlink` — a relationship for an address, `w:anchor` for one of the document's own anchors — and a run's own link wins over the paragraph's — in a list item as much as in a paragraph. An `anchor(...)` becomes a bookmark wrapping that paragraph's text, named as Word requires; on a section, container, table or image it wraps everything the block wrote, from the start of its first paragraph to the end of its last, so a link to a block lands on its first line. A `bookmark(...)` outline level becomes Word's own `HeadingN` style, which is what puts the paragraph in the Navigation Pane, the outline view and a generated table of contents. The style states the outline level and nothing else, so the paragraph keeps its own formatting. The role comes from what the document declared, never from how big the text is |
-| Rows | A one-row table spanning the content width, so editors keep the side-by-side layout. The row's slots become the column grid when they are weights, an even split or fixed columns; fixed columns that add up to less than the row leave the rest of it empty, the last one at its own width and the table no wider than its columns, so its text wraps where the page wraps it; the gap and the row's padding ride in the neighbouring column and come back out as that cell's margin; a cell holds whatever its child is, written as it is anywhere else. The row's `verticalAlign` is every cell's `w:vAlign`, so a child shorter than the row sits at its middle or bottom as on the page — a table of contents' leader on its entry's baseline. The row is kept whole across a page break, as the layout keeps it. A row is held at least as tall as the page makes it inside a painted panel, and anywhere its tallest child is a drawing Word holds nothing of in its cell — a badge beside a heading. A section or other container in any cell — a row's, a table's, a panel's — that pulls its first line up with a negative top edge writes that one-line paragraph as much shorter, its text seated where the page sets it — no more than the room above its letters, as Word draws an exact line's text only inside the line |
+| Rows | A one-row table spanning the content width — or only its columns, where fixed columns leave part of the row empty — so editors keep the side-by-side layout. The row's slots become the column grid when they are weights, an even split or fixed columns; fixed columns that add up to less than the row leave the rest of it empty, the last one at its own width and the table no wider than its columns, so its text wraps where the page wraps it; the gap and the row's padding ride in the neighbouring column and come back out as that cell's margin; a cell holds whatever its child is, written as it is anywhere else. The row's `verticalAlign` is every cell's `w:vAlign`, so a child shorter than the row sits at its middle or bottom as on the page — a table of contents' leader on its entry's baseline. The row is kept whole across a page break, as the layout keeps it. A row is held at least as tall as the page makes it inside a painted panel, and anywhere its tallest child is a drawing Word holds nothing of in its cell — a badge beside a heading. A section or other container in any cell — a row's, a table's, a panel's — that pulls its first line up with a negative top edge writes that one-line paragraph as much shorter, its text seated where the page sets it — no more than the room above its letters, as Word draws an exact line's text only inside the line |
| Sections / containers | Children written in order. A timeline with its markers on the rail (`markerOnRail()`) lays each entry's body out in the header row's content column, below the row; the body is written in the flow, indented to that column where the page puts it. A container with a fill, per-side borders or a uniform stroke is a one-cell table carrying them, its padding as the cell's margins, so a card keeps its panel — see "What a panel keeps and loses" below. A `keepTogether()` or `keepWithNext()` block the layout placed on one page stays on one page in Word too (`w:keepLines` + `w:keepNext`, and a row that may not split for a panel) |
| Spacers | An empty paragraph a tenth of a point tall; the spacer's height is the space above the next block, or below this paragraph when a table follows |
| Page breaks | Explicit Word page breaks |
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 e7826e703..c58a84058 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
@@ -8851,8 +8851,9 @@ private void writeRowCellChild(XWPFTableCell cell, DocumentNode child) throws Ex
*
* A row occupies the whole width it is offered — {@code measureRow} returns the
* available width unconditionally, whatever its children measure — so the carrier
- * gets the content width. Left at POI's size-to-content default the pair collapses
- * around its text instead.
+ * gets the content width, unless fixed columns add up to less and leave the rest of
+ * the row empty ({@link #holdTheLastFixedColumn}): then it is as wide as its columns.
+ * Left at POI's size-to-content default the pair collapses around its text instead.
*
* How that width divides is arithmetic the document already carries, for every
* distribution except one. Weights, an even split and fixed columns are shares of the
@@ -8923,7 +8924,10 @@ private static void holdTheLastFixedColumn(RowNode node, List column
}
int last = columns.size() - 1;
CellColumn column = columns.get(last);
- double held = column.leading() + specs.get(last).value() + column.trailing();
+ // The column starts where its child does, past the child's left margin; its slot
+ // starts that margin earlier.
+ double margin = node.children().size() == columns.size() ? node.children().get(last).margin().left() : 0;
+ double held = column.leading() - margin + specs.get(last).value() + column.trailing();
if (held < column.width() - 0.01) {
columns.set(last, new CellColumn(held, column.leading(), column.trailing()));
}
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxEditorSlackTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxEditorSlackTest.java
index 10920a69c..e48984243 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxEditorSlackTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxEditorSlackTest.java
@@ -97,10 +97,13 @@ void aRowWithNoAutoColumnIsWrittenAsPlaced() throws Exception {
assertThat(exported.gridTwips(0)).isEqualTo(exported.placedTwips(0));
assertThat(exported.gridTwips(1)).isEqualTo(exported.placedTwips(1));
+ assertThat(DocxTwips.of(exported.table().getCTTbl().getTblPr().getTblW().getW()))
+ .as("columns that fill the row keep the table at its width")
+ .isEqualTo(exported.placedTwips(0) + exported.placedTwips(1));
}
@Test
- void aRowWithNoWeightColumnHasNoPointToGiveAndIsWrittenAsPlaced() throws Exception {
+ void aRowWithNoWeightColumnHasNoPointToGiveItsFixedColumnAtItsWidth() throws Exception {
ExportedRow exported = exportRow(row -> row
.columns(DocumentRowColumn.auto(), DocumentRowColumn.fixed(80))
.addParagraph("Label").addParagraph("Value"));
diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRowLayoutTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRowLayoutTest.java
index 128916853..943ec8eed 100644
--- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRowLayoutTest.java
+++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxRowLayoutTest.java
@@ -146,13 +146,16 @@ void aFixedColumnTakesItsWidthAndAWeightTakesTheRest() throws Exception {
.addParagraph(p -> p.text("Value"))));
assertThat(gridTwips(table)).containsExactly(2000L, 5200L);
+ assertThat(twips(table.getCTTbl().getTblPr().getTblW().getW()))
+ .as("columns that fill the row keep the table at its width")
+ .isEqualTo(7200L);
}
@Test
void fixedColumnsNarrowerThanTheRowLeaveTheRestOfItEmpty() throws Exception {
// The last column wraps its text at its own width, laid out or not; the rest of the
// row is empty, and the table is no wider than its columns.
- java.util.function.Consumer row = page -> page.addRow(r -> r
+ Consumer row = page -> page.addRow(r -> r
.columns(DocumentRowColumn.fixed(100), DocumentRowColumn.fixed(80))
.addParagraph(p -> p.text("Label"))
.addParagraph(p -> p.text("Value")));
@@ -163,6 +166,34 @@ void fixedColumnsNarrowerThanTheRowLeaveTheRestOfItEmpty() throws Exception {
}
}
+ @Test
+ void aLastFixedColumnHoldsItsChildsMarginInsideItsWidth() throws Exception {
+ // The cell starts where the child does, past its 10pt margin; the 80pt slot ends 70 on.
+ XWPFTable table = measuredTable(page -> page.addRow(r -> r
+ .columns(DocumentRowColumn.fixed(100), DocumentRowColumn.fixed(80))
+ .addParagraph(p -> p.text("Label"))
+ .addParagraph(p -> p.text("Value").margin(new DocumentInsets(0, 0, 0, 10)))));
+
+ assertThat(gridTwips(table).get(1)).isEqualTo(70 * 20L);
+ }
+
+ @Test
+ void fixedColumnsNarrowerThanTheRowKeepItsPaddingAtTheirOuterEdges() throws Exception {
+ // 12pt of padding each side: it rides in the first and the last column, both of which
+ // end where their slots do, and the table ends with the last column's margin.
+ Consumer row = page -> page.addRow(r -> r
+ .padding(DocumentInsets.symmetric(0, 12))
+ .columns(DocumentRowColumn.fixed(100), DocumentRowColumn.fixed(80))
+ .addParagraph(p -> p.text("Label"))
+ .addParagraph(p -> p.text("Value")));
+
+ for (XWPFTable table : List.of(onlyTable(row), measuredTable(row))) {
+ assertThat(gridTwips(table)).containsExactly(112 * 20L, 92 * 20L);
+ assertThat(marginTwips(table, 1)).containsExactly(0L, 240L);
+ assertThat(twips(table.getCTTbl().getTblPr().getTblW().getW())).isEqualTo(204 * 20L);
+ }
+ }
+
@Test
void theRowsPaddingRidesInTheOuterColumnsAndComesBackOutAsAMargin() throws Exception {
// 12pt each side leaves 336 to halve. The padding is part of the table, which