From 9c3723f2239c797ff72d6a0d816f9693228248e6 Mon Sep 17 00:00:00 2001
From: DemchaAV
+ 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 2facc29b103d4257db1d55df28b19ec2e176f7b8..52c96b0bd6fd36ede3d4c4ec538dec83bfc587f9 100644
GIT binary patch
delta 2615
zcmYM0c{me}AIE3QeY4G3*o4T{kV4FnMN1+3DkgUxB4
zJ_~U*z};Hy!0E=sR{)jRBPiRba>euPwo&c-K(Dgap$Ap+wqJg5yF+c={93pkD*M;Y
zd<}=_hEgd(5Zcr-s)0v(F?iB}Ju$~$=qmf~-+J&@K=a+BGPY6qep^^}*ZcnYQ2f%S
z5+l$GE28?*Evh2vgHq{74g;_a&0RT6RG{HANHXyY+58S&XDI|djCK6eF!3g%eiPxsE3hRC0`q53x$?5LyWniRK|M1R-Ed|Gk1otM6Mif6iR9w&Bd;!guc
zjWL6q99H>m9q5&Xd2%kI>tuzCp+ot^xX5)>gC07wBmF3GA@p&>ODfj62wsWya5f#+
zyEXMZqg^biM6I2m{hM(@%5}OWmnr|GW$#SRx*;1Y-ofhClNFo*K%4ph;{D%ruj%4b
z{-=L!^23E#JxubL*FQZ>ja`hKA@hu7A!JSdu|nhwS=EdpcbX+@a{64DIvr
7$=t8g
z_OiVp+^WzexaynL?68vy5vc-{khKo)=t}XYQI
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