diff --git a/CHANGELOG.md b/CHANGELOG.md
index a8fb0316e..ee77fbc10 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,20 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A table cell keeps the space the document put inside its edges.** It was never written,
+ so Word used its own — 5.4pt at each side and *nothing* above or below. A row's height in
+ Word is its content's box, so every row came out shorter than the page draws it: measured
+ on the probe corpus through LibreOffice, each row of a five-row table sat 8.1pt short, and
+ the table's last row 40pt above where the page puts it. The padding a cell resolves to —
+ the table's default, then the column's, then the row's, then the cell's own — is now
+ written as `w:tcMar`, and the same table's rows land within 0.1pt of the page.
+
+ All four sides are written, and written even when they are zero, because Word's default is
+ not zero: a table that asked for no padding would otherwise export with Word's side
+ margins and read wider than it is. A table that states no padding at all is written with
+ the padding the engine lays it out with rather than with Word's, and a test pins the two
+ together so the export cannot quietly draw rows of a height nothing asked for.
+
- **A gap between two blocks in an exported Word file is one gap.** It was written from both
sides — `w:after` on the block above and `w:before` on the one below — which is the same
distance only in an editor that adds them. LibreOffice takes the larger: measured on the
diff --git a/assets/readme/examples/word-export-companion.docx b/assets/readme/examples/word-export-companion.docx
index 2facc29b1..52c96b0bd 100644
Binary files a/assets/readme/examples/word-export-companion.docx and b/assets/readme/examples/word-export-companion.docx differ
diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md
index cee9c59b8..2d628d166 100644
--- a/docs/recipes/docx-export.md
+++ b/docs/recipes/docx-export.md
@@ -43,7 +43,7 @@ PDF never pull POI.
|---|---|
| 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. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back" |
+| 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. The width is written when the document states one or every column is fixed; otherwise Word sizes the table — see "What falls back" |
| 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 79cfa7b7e..e41ad3f36 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
@@ -39,6 +39,7 @@
import com.demcha.compose.document.node.TextAlign;
import com.demcha.compose.document.style.DocumentBorders;
import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.style.DocumentRowColumn;
import com.demcha.compose.document.style.DocumentStroke;
import com.demcha.compose.document.style.DocumentTextStyle;
@@ -2027,6 +2028,7 @@ private void writeTable(XWPFDocument document, TableNode node) throws Exception
applyCellPaint(cell,
resolveCellValue(node, placement, DocumentTableStyle::fillColor),
resolveCellValue(node, placement, DocumentTableStyle::stroke));
+ applyCellPadding(cell, resolveCellPadding(node, placement));
if (placement.row() != rowIdx) {
// A covered position carries the merge marker and no content of its own.
continue;
@@ -2111,6 +2113,70 @@ private static void paintEdge(CTBorder edge, STBorder.Enum kind, BigInteger eigh
}
}
+ /**
+ * Keeps a cell's own space clear inside its edges.
+ *
+ *
A table's rows came out shorter than the page draws them: measured on the probe + * corpus, every row of a five-row table was 8.1pt short, because the cell padding the + * engine lays out with was never written and Word used its own — 5.4pt at each side and + * nothing above or below. Word holds this natively as {@code w:tcMar}, so it + * is a mapping rather than an approximation.
+ * + *All four sides are written, and written even when they are zero, because Word's + * default is not zero: a table that asked for no padding would otherwise export with + * Word's side margins and read wider than it is. The vertical pair is what a reader + * sees as the row's height, since Word grows a row to fit its content and this is part + * of that content's box.
+ */ + private static void applyCellPadding(XWPFTableCell cell, DocumentInsets padding) { + CTTcPr properties = cellProperties(cell); + CTTcMar margins = properties.isSetTcMar() ? properties.getTcMar() : properties.addNewTcMar(); + setCellMargin(margins.isSetTop() ? margins.getTop() : margins.addNewTop(), padding.top()); + setCellMargin(margins.isSetBottom() ? margins.getBottom() : margins.addNewBottom(), + padding.bottom()); + setCellMargin(margins.isSetLeft() ? margins.getLeft() : margins.addNewLeft(), padding.left()); + setCellMargin(margins.isSetRight() ? margins.getRight() : margins.addNewRight(), + padding.right()); + } + + /** + * The padding a cell resolves to, most specific wins, with the engine's own default + * underneath. + * + *The default is the last step of the same cascade the layout pipeline runs, where + * {@code TableCellLayoutStyle.DEFAULT} sits under the authored styles — so a table that + * states no padding is laid out with 4pt and has to be written with 4pt. + * {@code DocxCellPaddingTest} pins the two together, because the engine's copy is + * internal and cannot be read from here.
+ */ + private DocumentInsets resolveCellPadding(TableNode node, TableGrid.Placement placement) { + DocumentInsets authored = resolveCellValue(node, placement, DocumentTableStyle::padding); + return authored != null ? authored : DocumentInsets.of(ENGINE_DEFAULT_CELL_PADDING_POINTS); + } + + /** What the engine lays a cell out with when nothing states otherwise. */ + static final double ENGINE_DEFAULT_CELL_PADDING_POINTS = 4.0; + + /** The left and right margins written on a cell, or Word's own default for an unwritten one. */ + private static double horizontalMarginsOf(XWPFTableCell cell) { + CTTcPr properties = cell.getCTTc().isSetTcPr() ? cell.getCTTc().getTcPr() : null; + CTTcMar margins = properties != null && properties.isSetTcMar() ? properties.getTcMar() : null; + if (margins == null) { + return 2 * WORD_DEFAULT_CELL_MARGIN_POINTS; + } + return marginPoints(margins.isSetLeft() ? margins.getLeft() : null) + + marginPoints(margins.isSetRight() ? margins.getRight() : null); + } + + private static double marginPoints(CTTblWidth margin) { + return margin == null || margin.getW() == null + ? WORD_DEFAULT_CELL_MARGIN_POINTS + : Long.parseLong(String.valueOf(margin.getW())) / POINT_TO_TWIP; + } + + /** Word keeps this much clear inside every cell edge unless a table says otherwise. */ + private static final double WORD_DEFAULT_CELL_MARGIN_POINTS = 5.4; + private static CTTcPr cellProperties(XWPFTableCell cell) { return cell.getCTTc().isSetTcPr() ? cell.getCTTc().getTcPr() @@ -2687,14 +2753,12 @@ private static boolean isSemanticallyTransparent(DocumentNode node) { * @param columns column count of the first row * @return the created table, already attached where it belongs */ - /** Word keeps this much clear inside every cell edge unless a table says otherwise. */ - private static final double DEFAULT_CELL_MARGIN_POINTS = 5.4; - /** - * How wide content can be inside one cell: the columns it spans, less Word's margins. + * How wide content can be inside one cell: the columns it spans, less its own margins. * - *Read back from the grid this export just wrote rather than recomputed, so a cell - * cannot disagree with the table it is in.
+ *Both are read back from what this export just wrote — the grid for the width and + * {@code w:tcMar} for the margins — so a cell cannot disagree with the table it is in, + * and a table whose padding the document stated is not measured against Word's.
* * @return the usable width in points, or {@code NaN} when the table has no written grid */ @@ -2708,7 +2772,7 @@ private static double usableWidthOf(XWPFTableCell cell, TableGrid.Placement plac for (int index = placement.column(); index < last; index++) { twips += Long.parseLong(String.valueOf(grid.getGridColArray(index).getW())); } - double points = twips / POINT_TO_TWIP - 2 * DEFAULT_CELL_MARGIN_POINTS; + double points = twips / POINT_TO_TWIP - horizontalMarginsOf(cell); return points > 0 ? points : Double.NaN; } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellPaddingTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellPaddingTest.java new file mode 100644 index 000000000..f90a8174d --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCellPaddingTest.java @@ -0,0 +1,125 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.table.DocumentTableCell; +import com.demcha.compose.document.table.DocumentTableColumn; +import com.demcha.compose.document.table.DocumentTableStyle; +import com.demcha.compose.engine.components.content.table.TableCellLayoutStyle; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFTable; +import org.apache.poi.xwpf.usermodel.XWPFTableCell; +import org.junit.jupiter.api.Test; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblWidth; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcMar; + +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A table cell keeps the space the document put inside its edges. + * + *It was never written, so Word used its own — 5.4pt at each side and nothing + * above or below. Measured on the probe corpus, every row of a five-row table came out + * 8.1pt short of the page, because a row's height is its content's box and the padding is + * part of that box.
+ * + *Word holds this as {@code w:tcMar}, so it is a mapping. All four sides are written, + * and written even when they are zero: Word's default is not zero, so a table that asked + * for no padding would otherwise export with Word's side margins.
+ * + * @author Artem Demchyshyn + */ +class DocxCellPaddingTest { + + private static final double TWIPS_PER_POINT = 20.0; + + @Test + void aStatedPaddingReachesAllFourSides() throws Exception { + XWPFTableCell cell = firstCell(page -> page.addTable(t -> t + .columns(DocumentTableColumn.auto()) + .defaultCellStyle(DocumentTableStyle.builder() + .padding(new DocumentInsets(9, 7, 5, 3)) + .build()) + .row("Padded"))); + + assertThat(margins(cell)).containsExactly(180L, 140L, 100L, 60L); + } + + @Test + void aTableThatStatesNothingIsWrittenWithTheEnginesOwnDefault() throws Exception { + // Not Word's 5.4pt a side and nothing above: the page is laid out with 4pt all + // round, and the file has to say the same or the rows come out shorter than drawn. + XWPFTableCell cell = firstCell(page -> page.addTable(t -> t + .columns(DocumentTableColumn.auto()) + .row("Plain"))); + + long four = Math.round(4 * TWIPS_PER_POINT); + assertThat(margins(cell)).containsExactly(four, four, four, four); + } + + @Test + void theDefaultIsTheOneTheEngineLaysOutWith() throws Exception { + // The engine's copy is internal and cannot be read from the backend, so the two + // are pinned together here: if the layout's default moves, this fails rather than + // the export quietly drawing rows of a height nothing asked for. + assertThat(DocxSemanticBackend.ENGINE_DEFAULT_CELL_PADDING_POINTS) + .isEqualTo(TableCellLayoutStyle.DEFAULT.padding().top()) + .isEqualTo(TableCellLayoutStyle.DEFAULT.padding().bottom()) + .isEqualTo(TableCellLayoutStyle.DEFAULT.padding().left()) + .isEqualTo(TableCellLayoutStyle.DEFAULT.padding().right()); + } + + @Test + void aCellsOwnPaddingBeatsTheRowsAndTheTablesTest() throws Exception { + // The same cascade the layout pipeline merges in, applied per field. + XWPFTable table = firstTable(page -> page.addTable(t -> t + .columns(DocumentTableColumn.auto(), DocumentTableColumn.auto()) + .defaultCellStyle(DocumentTableStyle.builder() + .padding(DocumentInsets.of(2)) + .build()) + .rowStyle(0, DocumentTableStyle.builder().padding(DocumentInsets.of(6)).build()) + .rowCells(DocumentTableCell.text("row wins"), + DocumentTableCell.text("cell wins").withStyle( + DocumentTableStyle.builder().padding(DocumentInsets.of(11)).build())))); + + assertThat(margins(table.getRow(0).getCell(0))[0]).isEqualTo(Math.round(6 * TWIPS_PER_POINT)); + assertThat(margins(table.getRow(0).getCell(1))[0]).isEqualTo(Math.round(11 * TWIPS_PER_POINT)); + } + + @Test + void aTableThatAsksForNoPaddingGetsNoneRatherThanWords() throws Exception { + XWPFTableCell cell = firstCell(page -> page.addTable(t -> t + .columns(DocumentTableColumn.auto()) + .defaultCellStyle(DocumentTableStyle.builder() + .padding(DocumentInsets.zero()) + .build()) + .row("Tight"))); + + assertThat(margins(cell)).containsExactly(0L, 0L, 0L, 0L); + } + + /** Top, right, bottom, left in twips. */ + private static long[] margins(XWPFTableCell cell) { + CTTcMar mar = cell.getCTTc().getTcPr().getTcMar(); + return new long[]{width(mar.getTop()), width(mar.getRight()), + width(mar.getBottom()), width(mar.getLeft())}; + } + + private static long width(CTTblWidth margin) { + return margin == null || margin.getW() == null + ? -1 + : Long.parseLong(String.valueOf(margin.getW())); + } + + private static XWPFTableCell firstCell(Consumer