From 1dcf9b77a72264ba7d627111484fad58a5a8acab Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Wed, 23 Sep 2026 17:13:57 +0100 Subject: [PATCH 1/2] fix(docx): keep two tables in a row from merging into one Word and LibreOffice join two tables with nothing between them into one, laying the second one's rows on the first one's grid; a row is written as a table, so two rows in a row merged too. A paragraph a tenth of a point tall now separates them, carrying the rest of the gap the layout keeps and the fill of the panel they sit in. Space owed below a table no longer lands on the paragraph above it: after a table is written the paragraph before it stops being the one that carries owed space, so a card ending in a table no longer opens its bottom padding between its title and the table. --- CHANGELOG.md | 9 ++ docs/recipes/docx-export.md | 2 +- .../semantic/docx/DocxSemanticBackend.java | 42 +++++++ .../semantic/docx/DocxAdjacentTablesTest.java | 113 ++++++++++++++++++ 4 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAdjacentTablesTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ccd06c96..6cfb111b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,15 @@ follow semantic versioning; release dates are ISO 8601. on the card's own paragraphs. A row placed directly in a bordered card keeps its cells shaded and no longer draws the card's outline around the text of each cell. +- **Two tables in a row stay two tables in Word.** Word and LibreOffice join two tables + with nothing between them into one and lay the second one's rows on the first one's + column grid; measured in LibreOffice, a zebra table followed by a narrower one came out + at half its width with its text broken letter by letter — and a row (`addRow`) is carried + as a table, so two rows in a row were joined the same way. A paragraph a tenth of a point tall now + separates them, carrying the rest of the gap the layout keeps between them and the fill + of a panel they sit in. Space owed below a table no longer lands on the paragraph above + it: a card ending in a table opened its bottom padding between its title and its table. + - **A table of contents keeps its page numbers in Word, and they stay right.** The DOCX export dropped every page reference, so `addTableOfContents(...)` reached Word with its entries and no numbers, and `addPageReference(...)` wrote nothing. Each is now Word's own `PAGEREF` diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index f4d4dafd5..4747f1b29 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -68,7 +68,7 @@ creation date is real metadata. |---|---| | Paragraphs | Word paragraphs with alignment, font, size, colour, bold/italic/underline; inline runs preserved | | Lists | Real Word lists: a `numbering.xml` definition per list, `w:numPr` on each item, and the authored marker as the level's text. Nesting is a list level, so Enter continues the list and Tab demotes an item. See "What a list becomes" below for the kinds that stay plain paragraphs | -| Tables | Word tables, one cell per cell. Each cell states its own padding as `w:tcMar`, on all four sides, so a row is as tall as the page draws it, and a cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A column sized to its content gets a point more than the page gives it, so the editor's font substitute cannot wrap its widest cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back". A table breaks across pages where the layout breaks it: every row the layout placed is kept whole (`w:cantSplit`), `repeatHeader(n)` rows repeat on each page (`w:tblHeader`) and stay with the row under them | +| Tables | Word tables, one cell per cell. Each cell states its own padding as `w:tcMar`, on all four sides, so a row is as tall as the page draws it, and a cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A column sized to its content gets a point more than the page gives it, so the editor's font substitute cannot wrap its widest cell. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back". A table breaks across pages where the layout breaks it: every row the layout placed is kept whole (`w:cantSplit`), `repeatHeader(n)` rows repeat on each page (`w:tblHeader`) and stay with the row under them. Two tables in a row — rows included, since a row is carried as a table — are kept apart by a paragraph a tenth of a point tall, holding the rest of the gap between them: an editor joins two tables with nothing between them into one | | Composed cells (`DocumentTableCell.node(...)`) | Written by the same writers that write that node anywhere else, so a cell built from an image, a list or a table carries it. A nested table is a real `w:tbl` followed by the paragraph Word requires a cell to end with, and takes the width of the column it sits in — the column's, not the one the page gives it, because the layout reports a composed cell's content under the owner's path | | 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 | 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 a4f930cfc..33d329657 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 @@ -3748,8 +3748,16 @@ private double nestedTableWidth() { } private XWPFTable newTable(XWPFDocument document, int rows, int columns) { + if (currentCell == null && endsWithATable(document.getBodyElements())) { + separateFromTheTableAbove(document); + } // Word has no space above a table, so the paragraph before it has to carry it. flushSpacingAfter(); + // Nor can that paragraph carry the space below the table: it sits above it. Space + // owed once the table is written goes to whatever paragraph follows, as space above + // it — and nowhere, if nothing follows — rather than back above the table, which is + // where it used to land: a card's bottom padding opened a gap over its last table. + lastBodyParagraph = null; if (currentCell == null) { return document.createTable(rows, columns); } @@ -3762,6 +3770,40 @@ private XWPFTable newTable(XWPFDocument document, int rows, int columns) { return nested; } + private static boolean endsWithATable(List elements) { + return !elements.isEmpty() && elements.get(elements.size() - 1) instanceof XWPFTable; + } + + /** + * Puts a paragraph between a table and the one about to follow it. + * + *

Two tables with nothing between them are one table to Word and to LibreOffice: the + * editor joins them, and the second table's rows are laid out on the first one's column + * grid. Measured in LibreOffice, a zebra table followed by a narrower one came out at half + * its width, its text broken letter by letter. A table, or a row carried as one, is often + * followed by another, and on the page there is a gap between them.

+ * + *

So the gap is written as a paragraph: a tenth of a point tall, with the rest of the + * space the layout keeps between the two above it, so the second table starts where the + * page starts it. It is an ordinary body paragraph, so a panel the tables sit in shades it + * and the band stays unbroken. Measured in LibreOffice, a one-point separator put 0.9pt + * more between two touching tables than a tenth of a point does; below that the height + * stops mattering.

+ */ + private void separateFromTheTableAbove(XWPFDocument document) { + pendingSpacingAfter = Math.max(0, pendingSpacingAfter - SEPARATOR_POINTS); + XWPFParagraph separator = newBodyParagraph(document); + CTPPr properties = separator.getCTP().isSetPPr() + ? separator.getCTP().getPPr() + : separator.getCTP().addNewPPr(); + CTSpacing spacing = properties.isSetSpacing() ? properties.getSpacing() : properties.addNewSpacing(); + spacing.setLineRule(STLineSpacingRule.EXACT); + spacing.setLine(BigInteger.valueOf(Math.round(SEPARATOR_POINTS * POINT_TO_TWIP))); + } + + /** How tall the paragraph keeping two tables apart is. */ + private static final double SEPARATOR_POINTS = 0.1; + /** * Writes one node into a cell, through the same writers that write it anywhere else. * diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAdjacentTablesTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAdjacentTablesTest.java new file mode 100644 index 000000000..ab0ad63e2 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAdjacentTablesTest.java @@ -0,0 +1,113 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.document.style.DocumentColor; +import org.apache.poi.xwpf.usermodel.IBodyElement; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.apache.poi.xwpf.usermodel.XWPFTable; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Two tables in a row stay two tables in Word. + * + *

An editor joins two tables with nothing between them into one, laying the second one's + * rows on the first one's column grid — measured in LibreOffice, a zebra table followed by a + * narrower one came out at half its width, its text broken letter by letter. A tenth-of-a-point + * paragraph now keeps them apart.

+ * + * @author Artem Demchyshyn + */ +class DocxAdjacentTablesTest { + + @Test + void twoTablesInARowAreKeptApartByAParagraphATenthOfAPointTall() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page + .addTable(t -> t.autoColumns(2).row("Item", "Amount")) + .addTable(t -> t.autoColumns(3).row("A", "B", "C")))) { + List body = document.getBodyElements(); + + assertThat(body).hasSize(3); + assertThat(body.get(0)).isInstanceOf(XWPFTable.class); + assertThat(body.get(1)).isInstanceOf(XWPFParagraph.class); + assertThat(body.get(2)).isInstanceOf(XWPFTable.class); + XWPFParagraph separator = (XWPFParagraph) body.get(1); + assertThat(separator.getText()).isEmpty(); + assertThat(DocxTwips.of(separator.getCTP().getPPr().getSpacing().getLine())) + .as("a tenth of a point, so it keeps the tables apart without opening a line between them") + .isEqualTo(2L); + } + } + + @Test + void twoRowsInARowAreTwoTablesToo() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page + .addRow(r -> r.addParagraph(p -> p.text("Name")).addParagraph(p -> p.text("Role"))) + .addRow(r -> r.addParagraph(p -> p.text("Ada")).addParagraph(p -> p.text("Engineer"))))) { + List body = document.getBodyElements(); + + assertThat(body).extracting(element -> element.getClass().getSimpleName()) + .containsExactly("XWPFTable", "XWPFParagraph", "XWPFTable"); + } + } + + @Test + void theGapBetweenThemIsTheLayoutsLessTheSeparatorsPoint() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page + .addTable(t -> t.autoColumns(1).row("Above")) + .addTable(t -> t.autoColumns(1).row("Below").margin( + com.demcha.compose.document.style.DocumentInsets.top(12))))) { + XWPFParagraph separator = (XWPFParagraph) document.getBodyElements().get(1); + + assertThat(DocxTwips.of(separator.getCTP().getPPr().getSpacing().getBefore())) + .as("12pt above the second table: 11.9pt of spacing and the separator's own tenth of a point") + .isEqualTo(238L); + } + } + + @Test + void aParagraphAlreadyBetweenThemIsEnough() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page + .addTable(t -> t.autoColumns(1).row("Above")) + .addParagraph(p -> p.text("Between")) + .addTable(t -> t.autoColumns(1).row("Below")))) { + assertThat(document.getBodyElements()).hasSize(3); + assertThat(document.getParagraphs()).extracting(XWPFParagraph::getText).containsExactly("Between"); + } + } + + @Test + void spaceOwedBelowATableGoesBelowItNotAboveIt() throws Exception { + // A card ending in a table, followed by a page break: the card's bottom padding is + // owed after its table, and used to be paid on the last paragraph written — the + // card's title, above the table, opening a gap between the title and the table. + try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page + .addSection(s -> s.padding(com.demcha.compose.document.style.DocumentInsets.of(10)) + .addParagraph(p -> p.text("Title")) + .addTable(t -> t.autoColumns(1).row("Last"))) + .addPageBreak(b -> { }) + .addParagraph(p -> p.text("Next page")))) { + XWPFParagraph title = document.getParagraphs().get(0); + var spacing = title.getCTP().getPPr().getSpacing(); + + assertThat(spacing == null || !spacing.isSetAfter()) + .as("the card's bottom padding is below its table, not between its title and the table") + .isTrue(); + } + } + + @Test + void theSeparatorInsideAPanelKeepsTheBandUnbroken() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page + .addSection(s -> s.fillColor(DocumentColor.rgb(238, 243, 249)) + .addTable(t -> t.autoColumns(1).row("Above")) + .addTable(t -> t.autoColumns(1).row("Below"))))) { + XWPFParagraph separator = (XWPFParagraph) document.getBodyElements().get(1); + + assertThat(separator.getCTP().getPPr().isSetShd()).isTrue(); + } + } +} From 8cc836166dfb7c0114579fc8b7220414744efd49 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Wed, 23 Sep 2026 17:32:09 +0100 Subject: [PATCH 2/2] fix(docx): give the table separator a panel's fill, not its borders On a lone paragraph between two tables a stroked panel's borders drew a rule across the card at every junction; the separator now keeps the fill only. It also keeps with the table below it, so a keepWithNext block ending in a table stays kept and a bookmark opened on the separator counts the page the table lands on. After a table nested in a cell, space owed below it goes to the paragraph that closes the cell instead of being dropped. --- CHANGELOG.md | 12 +++-- .../semantic/docx/DocxSemanticBackend.java | 25 +++++++-- .../semantic/docx/DocxAdjacentTablesTest.java | 53 +++++++++++++++++++ 3 files changed, 81 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cfb111b4..6de2f4453 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,10 +21,14 @@ follow semantic versioning; release dates are ISO 8601. with nothing between them into one and lay the second one's rows on the first one's column grid; measured in LibreOffice, a zebra table followed by a narrower one came out at half its width with its text broken letter by letter — and a row (`addRow`) is carried - as a table, so two rows in a row were joined the same way. A paragraph a tenth of a point tall now - separates them, carrying the rest of the gap the layout keeps between them and the fill - of a panel they sit in. Space owed below a table no longer lands on the paragraph above - it: a card ending in a table opened its bottom padding between its title and its table. + as a table, so two rows in a row were joined the same way. A paragraph a tenth of a point + tall (measured in LibreOffice; Word may hold it to its own minimum, still under a point) now + separates them, carrying the rest of the gap the layout keeps between them and a panel's + fill — not its borders, which would rule a line across the card — and keeping with the + table below it, so a block kept with that table stays kept. Space owed below a table no + longer lands on the paragraph above it: a card ending in a table opened its bottom padding + between its title and its table, and after a table nested in a cell the space now goes to + the paragraph that closes the cell. - **A table of contents keeps its page numbers in Word, and they stay right.** The DOCX export dropped every page reference, so `addTableOfContents(...)` reached Word with its entries and 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 33d329657..d7f8025bc 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 @@ -3766,7 +3766,10 @@ private XWPFTable newTable(XWPFDocument document, int rows, int columns) { // it, so reading the cell back finds it. getTables() is unmodifiable on purpose — // adding to it throws rather than quietly leaving the model and the XML disagreeing. currentCell.insertTable(currentCell.getBodyElements().size(), nested); - currentCell.addParagraph(); + // Word ends a cell with a paragraph, so one follows the nested table — and being below + // it, it is where space owed after the table goes: a padded card ending in a nested + // table keeps its bottom padding inside the cell, as the page does. + lastBodyParagraph = currentCell.addParagraph(); return nested; } @@ -3785,10 +3788,16 @@ private static boolean endsWithATable(List elements) { * *

So the gap is written as a paragraph: a tenth of a point tall, with the rest of the * space the layout keeps between the two above it, so the second table starts where the - * page starts it. It is an ordinary body paragraph, so a panel the tables sit in shades it - * and the band stays unbroken. Measured in LibreOffice, a one-point separator put 0.9pt - * more between two touching tables than a tenth of a point does; below that the height - * stops mattering.

+ * page starts it. Measured in LibreOffice, a one-point separator put 0.9pt more between two + * touching tables than a tenth of a point does; below that the height stops mattering. + * Word may hold a line that short to its own minimum, which is still under a point.

+ * + *

It belongs to the gap, not to either table, and is written that way: it takes a + * panel's fill so the band is not broken, but not the panel's borders, which on a lone + * paragraph between two tables would draw a rule across the card at every junction; and it + * keeps with the next table, so a page never breaks between the gap and what it opens — a + * block kept with the table below it stays kept, and a bookmark opened on the separator + * counts the page the table lands on.

*/ private void separateFromTheTableAbove(XWPFDocument document) { pendingSpacingAfter = Math.max(0, pendingSpacingAfter - SEPARATOR_POINTS); @@ -3796,6 +3805,12 @@ private void separateFromTheTableAbove(XWPFDocument document) { CTPPr properties = separator.getCTP().isSetPPr() ? separator.getCTP().getPPr() : separator.getCTP().addNewPPr(); + if (properties.isSetPBdr()) { + properties.unsetPBdr(); + } + if (!properties.isSetKeepNext()) { + properties.addNewKeepNext(); + } CTSpacing spacing = properties.isSetSpacing() ? properties.getSpacing() : properties.addNewSpacing(); spacing.setLineRule(STLineSpacingRule.EXACT); spacing.setLine(BigInteger.valueOf(Math.round(SEPARATOR_POINTS * POINT_TO_TWIP))); diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAdjacentTablesTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAdjacentTablesTest.java index ab0ad63e2..9082eab4d 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAdjacentTablesTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxAdjacentTablesTest.java @@ -99,6 +99,59 @@ void spaceOwedBelowATableGoesBelowItNotAboveIt() throws Exception { } } + @Test + void theSeparatorTakesAPanelsFillButNotItsBorders() throws Exception { + // On a lone paragraph between two tables the panel's borders would draw a rule across + // the card at every junction. + try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page + .addSection(s -> s.fillColor(DocumentColor.rgb(238, 243, 249)) + .stroke(com.demcha.compose.document.style.DocumentStroke.of(DocumentColor.rgb(26, 86, 148), 1)) + .addParagraph(p -> p.text("Title")) + .addTable(t -> t.autoColumns(1).row("Above")) + .addTable(t -> t.autoColumns(1).row("Below"))))) { + XWPFParagraph separator = (XWPFParagraph) document.getBodyElements().get(2); + + assertThat(separator.getCTP().getPPr().isSetShd()).isTrue(); + assertThat(separator.getCTP().getPPr().isSetPBdr()).isFalse(); + assertThat(document.getParagraphs().get(0).getCTP().getPPr().isSetPBdr()) + .as("the panel's own paragraphs keep its borders") + .isTrue(); + } + } + + @Test + void theSeparatorKeepsWithTheTableItOpens() throws Exception { + // A block kept with what follows it, ending in a table: its last row keeps with the + // separator, so the separator has to keep with the next table or the chain breaks. + try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page + .addSection(s -> s.keepWithNext().addTable(t -> t.autoColumns(1).row("Kept"))) + .addTable(t -> t.autoColumns(1).row("Next")))) { + XWPFParagraph separator = (XWPFParagraph) document.getBodyElements().get(1); + + assertThat(separator.getCTP().getPPr().isSetKeepNext()).isTrue(); + } + } + + @Test + void spaceOwedAfterANestedTableStaysInsideTheCell() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page + .addTable(t -> t.autoColumns(1).rowCells( + com.demcha.compose.document.table.DocumentTableCell.node( + new com.demcha.compose.document.dsl.SectionBuilder() + .padding(com.demcha.compose.document.style.DocumentInsets.of(8)) + .addParagraph(p -> p.text("Totals")) + .addTable(inner -> inner.autoColumns(1).row("100")) + .build()))))) { + var cell = document.getTables().get(0).getRow(0).getCell(0); + List paragraphs = cell.getParagraphs(); + XWPFParagraph afterNested = paragraphs.get(paragraphs.size() - 1); + + assertThat(DocxTwips.of(afterNested.getCTP().getPPr().getSpacing().getAfter())) + .as("the padding below the nested table is written below it, inside the cell") + .isEqualTo(8 * 20L); + } + } + @Test void theSeparatorInsideAPanelKeepsTheBandUnbroken() throws Exception { try (XWPFDocument document = DocxExports.withLayout(595, 842, 36, page -> page