diff --git a/CHANGELOG.md b/CHANGELOG.md index d7104f735..8dad04d20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **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 + margin out of the width it is given there, rather than running past the cell. A table that + opens a layer of a stack written as a band starts where the band resumes and keeps its top + margin below that, held by a hairline line at the top of a cell. `VioletGrid`'s education lines, a table beside a badge + held 51.3pt clear of it and 2.3pt down, started under the badge and stood 2.4pt high; they + are at most 0.07pt off now. Lines more than 2pt off in Word fall from 5 to 2 for `VioletGrid`. - **Text hanging past a block takes its place out of the space above a stack that follows in DOCX.** A stack written as a band opens at the space the page measures above its first block, and that block forgot what the text above it already hung into that space: a band's or a diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index c9b37abf8..25f65d6e6 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -252,8 +252,11 @@ paragraph a point tall after it: Word writes one there in any case, and its own text tall. The horizontal half is carried as an indent: outside any panel, every paragraph by each enclosing container's -margin and padding, a row or a table by the same amount as `w:tblInd` — see "What a panel -keeps and loses". +margin and padding, a row or a table by the same amount as `w:tblInd`, its own left margin +added unless it is a row's column, whose cell already starts past it — see "What a panel +keeps and loses". A table that opens a layer of a stack written as a band, or of a column +layer, starts where the layer resumes, its own top margin below that; at the top of a cell an +empty hairline line above it holds that space, as it does for a row. A right-to-left paragraph has its two indents written the other way round. In a `w:bidi` paragraph Word and LibreOffice read `w:ind`'s `left` and `right` as the start and end of 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 2e1447fb2..39a9b350b 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-linux-lines.tsv @@ -1779,9 +1779,9 @@ cv-violet_grid 0:999ddb26bed6 21.16 redesignedkeypatientflowsincludi cv-violet_grid 0:073d15633c0b 21.16 systemandcomponentlibraryusedacr cv-violet_grid 0:68bf0109c6f8 21.41 education cv-violet_grid 0:6cb574fa10b6 21.41 languages -cv-violet_grid 0:0cf7506cf94a 19.08 bacheloroffineartsingraphicdesig -cv-violet_grid 0:00ab7a6aa5f1 19.09 californiacollegeofthearts -cv-violet_grid 0:7e2c21bb41db 19.08 sanfrancisco,ca|2014-2018 +cv-violet_grid 0:0cf7506cf94a 21.48 bacheloroffineartsingraphicdesig +cv-violet_grid 0:00ab7a6aa5f1 21.49 californiacollegeofthearts +cv-violet_grid 0:7e2c21bb41db 21.48 sanfrancisco,ca|2014-2018 cv-violet_grid 0:b6234d2ea0d6 20.73 english cv-violet_grid 0:bef32d2c315a 37.47 native cv-violet_grid 0:bef32d2c315a#1 5.21 native diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-linux.tsv index 8130fb274..3f048ec40 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.31 39 +cv-violet_grid 1 2 80 76 5.21 21.41 39 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/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv index 697cb65ca..b14091dfd 100644 --- a/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/libreoffice-windows-lines.tsv @@ -1685,9 +1685,9 @@ cv-violet_grid 0:999ddb26bed6 21.16 redesignedkeypatientflowsincludi cv-violet_grid 0:073d15633c0b 21.16 systemandcomponentlibraryusedacr cv-violet_grid 0:68bf0109c6f8 21.41 education cv-violet_grid 0:6cb574fa10b6 21.41 languages -cv-violet_grid 0:0cf7506cf94a 19.08 bacheloroffineartsingraphicdesig -cv-violet_grid 0:00ab7a6aa5f1 19.09 californiacollegeofthearts -cv-violet_grid 0:7e2c21bb41db 19.08 sanfrancisco,ca|2014-2018 +cv-violet_grid 0:0cf7506cf94a 21.48 bacheloroffineartsingraphicdesig +cv-violet_grid 0:00ab7a6aa5f1 21.49 californiacollegeofthearts +cv-violet_grid 0:7e2c21bb41db 21.48 sanfrancisco,ca|2014-2018 cv-violet_grid 0:b6234d2ea0d6 20.73 english cv-violet_grid 0:bef32d2c315a 37.47 native cv-violet_grid 0:bef32d2c315a#1 5.21 native diff --git a/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv b/qa/src/test/resources/docx-fidelity/libreoffice-windows.tsv index f84de8629..b94487410 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.31 39 +cv-violet_grid 1 2 80 76 5.21 21.41 39 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 0cafde84c..6878c2617 100644 --- a/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv +++ b/qa/src/test/resources/docx-fidelity/word-windows-lines.tsv @@ -1778,9 +1778,9 @@ cv-violet_grid 0:999ddb26bed6 -0.33 redesignedkeypatientflowsincludi cv-violet_grid 0:073d15633c0b -0.29 systemandcomponentlibraryusedacr cv-violet_grid 0:68bf0109c6f8 -0.07 education cv-violet_grid 0:6cb574fa10b6 -0.07 languages -cv-violet_grid 0:0cf7506cf94a -2.47 bacheloroffineartsingraphicdesig -cv-violet_grid 0:00ab7a6aa5f1 -2.43 californiacollegeofthearts -cv-violet_grid 0:7e2c21bb41db -2.44 sanfrancisco,ca|2014-2018 +cv-violet_grid 0:0cf7506cf94a -0.07 bacheloroffineartsingraphicdesig +cv-violet_grid 0:00ab7a6aa5f1 -0.03 californiacollegeofthearts +cv-violet_grid 0:7e2c21bb41db -0.04 sanfrancisco,ca|2014-2018 cv-violet_grid 0:b6234d2ea0d6 -1.38 english cv-violet_grid 0:bef32d2c315a -1.37 native cv-violet_grid 0:bef32d2c315a#1 -1.29 native diff --git a/qa/src/test/resources/docx-fidelity/word-windows.tsv b/qa/src/test/resources/docx-fidelity/word-windows.tsv index ca33ebb8e..df8f19b25 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.23 1.30 5 +cv-violet_grid 1 1 80 77 0.22 1.09 2 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 94c825bdc..8566239d9 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 @@ -7366,8 +7366,9 @@ private static boolean startsWith(byte[] bytes, int... signature) { * paragraph after. Both go through the debt every other gap goes through * ({@link #owePendingSpacingAfter}), so a table between two paragraphs reads the same as * two paragraphs with a gap between them. With nothing above it — opening the body, or a - * row opening any cell but a table's — a paragraph a tenth of a point tall holds that edge - * ({@link #holdTheSpaceAboveATable}); a table opening a cell still loses it.

+ * row opening any cell but a table's, or a table opening a cell where a band or column layer + * resumes — a paragraph a tenth of a point tall holds that edge + * ({@link #holdTheSpaceAboveATable}); any other table opening a cell still loses it.

*/ private void writeTableWithItsOwnSpacing(XWPFDocument document, DocumentNode node) throws Exception { @@ -7384,11 +7385,21 @@ private void writeTableWithItsOwnSpacing(XWPFDocument document, DocumentNode nod borderBelow = 0; forgetTheHang(); } + // A table a band's or a column's layer opens is that layer's first block, so it starts + // where the layer resumes, as a paragraph does, and its own top margin comes after: + // VioletGrid's education lines, beside a badge, lost their 2.3pt and stood 2.4pt high. + // A row is not a first block: the band measures to the first one inside it, and the + // row's cells write what stands above that block. + boolean resumed = node instanceof TableNode && !Double.isNaN(resumeSpacing); + if (resumed) { + resumeHere(); + } owePendingSpacingAfter(node.margin().top() + node.padding().top()); if (onANewPage) { holdTheSpaceAboveOnItsPage(document); } - if (node instanceof RowNode && currentCell != null && !tablesCells.contains(currentCell.getCTTc())) { + if ((node instanceof RowNode || resumed) && currentCell != null + && !tablesCells.contains(currentCell.getCTTc())) { // At the top of a cell nothing above holds that space: MerchantInvoice's due-date // row lost its 16.7pt of top padding and stood against the card's top edge, once the // card held the page's height, and PaymentsInvoice's metadata grid the 6.2pt its @@ -7398,10 +7409,23 @@ private void writeTableWithItsOwnSpacing(XWPFDocument document, DocumentNode nod // 6pt below the page's. holdTheSpaceAboveATable(document); } - if (node instanceof RowNode row) { - writeRow(document, row); - } else { - writeTable(document, (TableNode) node); + // Its side margins hold it in from the edges it is written between, as a paragraph's do: + // VioletGrid's education lines, a table held 51.3pt clear of the badge beside them, + // started under the badge, and PaymentsInvoice's bank details 36.7pt left of the page's. + double outerLeft = insetLeft; + double outerRight = insetRight; + // A row's column already starts past the margin of the block the layout placed in it. + insetLeft += node == leftMarginInCell ? 0 : node.margin().left(); + insetRight += node.margin().right(); + try { + if (node instanceof RowNode row) { + writeRow(document, row); + } else { + writeTable(document, (TableNode) node); + } + } finally { + insetLeft = outerLeft; + insetRight = outerRight; } owePendingSpacingAfter(node.margin().bottom() + node.padding().bottom()); } @@ -10062,7 +10086,8 @@ private static double usableWidthOf(XWPFTableCell cell, int column, int span) { * Word to about one character per line, which is not a document anybody can read.

*/ private double nestedTableWidth() { - return currentCellWidth; + // Less what the table is held in by, its own margins included (writeTableWithItsOwnSpacing). + return currentCellWidth - Math.max(0, insetLeft) - Math.max(0, insetRight); } private XWPFTable newTable(XWPFDocument document, int rows, int columns) { diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableMarginsTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableMarginsTest.java new file mode 100644 index 000000000..7417cc3e9 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxTableMarginsTest.java @@ -0,0 +1,183 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.dsl.ParagraphBuilder; +import com.demcha.compose.document.dsl.SectionBuilder; +import com.demcha.compose.document.dsl.ShapeContainerBuilder; +import com.demcha.compose.document.dsl.TableBuilder; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.LayerAlign; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentStroke; +import com.demcha.compose.document.table.DocumentTableCell; +import com.demcha.compose.document.table.DocumentTableColumn; +import com.demcha.compose.document.table.DocumentTableStyle; +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.apache.poi.xwpf.usermodel.XWPFTableCell; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A table keeps its own margins: its left margin holds it in from the edge it is written from, + * and its top margin stands above it, in a band's layer as anywhere else. + * + *

VioletGrid sets each education entry's lines in a table held 51.3pt clear of a badge + * drawn beside them, and 2.3pt down. Neither margin was written: in Word the lines started + * under the badge and stood 2.4pt high. A table or a row was never indented by its left + * margin, anywhere — PaymentsInvoice's bank details stood 36.7pt left of the page's.

+ */ +class DocxTableMarginsTest { + + private static final double INSET = 51.3; + private static final double DOWN = 2.3; + + @Test + void aTablesLeftMarginIndentsIt() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(500, 600, 20, page -> page + .addParagraph("Above") + .add(lines()))) { + assertThat(indentOf(document.getTables().get(0))).as("the table stands its margin in") + .isEqualTo(Math.round(INSET * 20)); + } + } + + @Test + void aRowsLeftMarginIndentsIt() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(500, 600, 20, page -> page + .addParagraph("Above") + .addRow(row -> row.name("Split").margin(new DocumentInsets(0, 0, 0, 30)) + .addSection("Left", left -> left.addParagraph("Left")) + .addSection("Right", right -> right.addParagraph("Right"))))) { + assertThat(indentOf(document.getTables().get(0))).as("the row stands its margin in") + .isEqualTo(600); + } + } + + @Test + void aTableBesideABadgeKeepsItsMarginsInTheBand() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(500, 600, 20, page -> { + page.addParagraph("Above"); + body(page); + })) { + XWPFTable lines = document.getTables().get(0); + assertThat(lines.getText()).contains("Degree"); + assertThat(indentOf(lines)).as("clear of the badge, as the page sets it") + .isEqualTo(Math.round(INSET * 20)); + XWPFParagraph above = document.getParagraphs().get(0); + assertThat(above.getText()).isEqualTo("Above"); + assertThat(above.getSpacingAfter()).as("and its top margin stands above it") + .isEqualTo((int) Math.round(DOWN * 20)); + } + } + + @Test + void aTableOpeningACellBesideABadgeKeepsItsTopMarginOverIt() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(500, 600, 20, page -> page + .addParagraph("Above") + .addRow(row -> row.name("Columns") + .addSection("Education", this::bodyIn) + .addSection("Other", other -> other.addParagraph("Other"))))) { + XWPFTableCell cell = document.getTables().get(0).getRow(0).getCell(0); + List elements = cell.getBodyElements(); + assertThat(elements.get(0)).as("nothing above the table in its cell holds the space") + .isInstanceOf(XWPFParagraph.class); + XWPFParagraph hairline = (XWPFParagraph) elements.get(0); + assertThat(hairline.getText()).isEmpty(); + assertThat(hairline.getSpacingBefore()).as("so a hairline over it holds its top margin") + .isEqualTo((int) Math.round(DOWN * 20)); + XWPFTable lines = (XWPFTable) elements.get(1); + assertThat(indentOf(lines)).isEqualTo(Math.round(INSET * 20)); + } + } + + @Test + void aTableARowPlacesAsAColumnIsNotIndentedTwice() throws Exception { + // The row's cell starts where the layout placed the table, past its margin already. + // RowBuilder refuses a table, and a row node built by hand holds one. + DocumentNode row = new com.demcha.compose.document.node.RowNode("Columns", + List.of(lines(), new SectionBuilder().name("Other").addParagraph("Other").build()), + List.of(1.0, 1.0), 0, DocumentInsets.zero(), DocumentInsets.zero(), null, null, + com.demcha.compose.document.style.DocumentCornerRadius.ZERO, null, List.of()); + try (XWPFDocument document = DocxExports.withLayout(500, 600, 20, page -> page.add(row))) { + XWPFTableCell cell = document.getTables().get(0).getRow(0).getCell(0); + XWPFTable lines = cell.getTables().get(0); + assertThat(lines.getText()).contains("Degree"); + assertThat(indentOf(lines)).as("the column holds the margin, not the table").isZero(); + } + } + + @Test + void aTableIndentedInACellStillEndsInsideIt() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(500, 600, 20, page -> page + .addRow(row -> row.name("Columns") + .addSection("Left", section -> section.add(new TableBuilder().name("Auto") + .margin(new DocumentInsets(0, 0, 0, 20)) + .columns(DocumentTableColumn.auto(), DocumentTableColumn.auto()) + .row("A rather longer cell of text", "Another rather long cell") + .build())) + .addSection("Right", right -> right.addParagraph("Right"))))) { + XWPFTableCell cell = document.getTables().get(0).getRow(0).getCell(0); + XWPFTable nested = cell.getTables().get(0); + long cellWidth = ((Number) cell.getCTTc().getTcPr().getTcW().getW()).longValue(); + long tableWidth = ((Number) nested.getCTTbl().getTblPr().getTblW().getW()).longValue(); + assertThat(indentOf(nested)).isEqualTo(400); + assertThat(indentOf(nested) + tableWidth).as("its margin comes out of its width, not past the cell") + .isLessThanOrEqualTo(cellWidth); + } + } + + @Test + void aTableOpeningACellOutsideABandHoldsNoHairline() throws Exception { + try (XWPFDocument document = DocxExports.withLayout(500, 600, 20, page -> page + .addRow(row -> row.name("Columns") + .addSection("Education", section -> section.add(lines())) + .addSection("Other", other -> other.addParagraph("Other"))))) { + XWPFTableCell cell = document.getTables().get(0).getRow(0).getCell(0); + assertThat(cell.getBodyElements().get(0)).as("nothing resumes there, so nothing is held") + .isInstanceOf(XWPFTable.class); + } + } + + private void bodyIn(SectionBuilder section) { + section.addLayerStack(stack -> stack.name("Body") + .layer(lines(), LayerAlign.TOP_LEFT, 0) + .position(badge(), 0, 0, LayerAlign.TOP_LEFT, 1)); + } + + private void body(PageFlowBuilder page) { + page.addSection("Education", this::bodyIn); + } + + private static DocumentNode lines() { + return new TableBuilder().name("Lines") + .margin(new DocumentInsets(DOWN, 0, 0, INSET)).width(150) + .columns(DocumentTableColumn.fixed(150)) + .defaultCellStyle(DocumentTableStyle.builder().padding(DocumentInsets.zero()) + .stroke(DocumentStroke.of(DocumentColor.WHITE, 0)).build()) + .rowCells(DocumentTableCell.node(new ParagraphBuilder().text("Degree").build())) + .rowCells(DocumentTableCell.node(new ParagraphBuilder().text("School").build())) + .build(); + } + + private static DocumentNode badge() { + return new ShapeContainerBuilder().name("Badge").circle(40) + .fillColor(DocumentColor.rgb(200, 180, 240)) + .center(new SectionBuilder().name("Glyph").spacer(10, 10).build()) + .build(); + } + + private static long indentOf(XWPFTable table) { + var properties = table.getCTTbl().getTblPr(); + if (properties == null || !properties.isSetTblInd()) { + return 0; + } + return ((Number) properties.getTblInd().getW()).longValue(); + } +}