From 4c082cbf01a1911a41e541272314542519e23c33 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Wed, 30 Sep 2026 21:29:18 +0100 Subject: [PATCH 1/2] fix(docx): give a row's cells its smallest vertical margins, the rest of their padding in their paragraphs, and hold a composed chip to its outline --- CHANGELOG.md | 14 ++ docs/recipes/docx-export.md | 9 +- .../semantic/docx/DocxSemanticBackend.java | 130 +++++++++++++++++- .../semantic/docx/DocxCellPaddingTest.java | 75 +++++++++- .../semantic/docx/DocxComposedCellTest.java | 6 +- 5 files changed, 222 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a11ec620d..5ff941d20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A table row whose cells are padded differently is as tall in Word as on the page.** Word and + LibreOffice give every cell of a row the largest top and bottom margin of any cell in it: + measured, a row whose day cells were padded 5.5pt above and 10.25pt below beside a label + padded 0.75pt stood 60.3pt tall in both, where its tallest cell came to 46. `CobaltRota`'s + masthead row stood 20.8pt taller than the page's, each staff row 2.4pt taller, and the rota ran + onto a second page. A row's cells are now written with its smallest vertical margins, and the + rest of each cell's padding as space above its first paragraph and below its last; a cell + opening with a table, or in a vertical merge, keeps its margins, and the row's comes down no + lower than the largest of them. A shape + composed in a table cell and written as a panel — a rota's shift chip — is held to its + outline's height, where it had closed round its line of text: 13pt for a 17.5pt chip. In both + editors `CobaltRota`'s median drift falls from 23.1pt to 8.9, and it fits one page again in + LibreOffice. + - **A line holding an icon keeps the page's height in Word.** A paragraph whose picture passes its text was written "at least" the picture's height, and Word, growing the line to its own measure, made it taller than the page: `TimelineMinimal`'s contact lines, a 10.5pt icon diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index c68504f59..2522fa0a7 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -69,7 +69,7 @@ creation date is real metadata. |---|---| | Paragraphs | Word paragraphs with alignment, font, size, colour, bold/italic/underline; inline runs preserved; a `\n` in the text is a line break (`w:br`), which Word would otherwise read as a space; a table cell's `text("a\nb")` stays one line, as the page sets it | | 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. Its padding above and below gives up the room Word makes for the table's horizontal rules — half of a rule between two rows to each, the lower row's rule where the two differ, the rule above the table and the one below it whole to their row — which the page does not (measured: a 0.75pt rule made each row 0.75pt taller). A table that states no rule is written with the engine's default 1pt black rule, as the page draws it, not left on Word's thinner grid. Its `textAnchor` becomes `w:vAlign` and the paragraph's `w:jc`, with the engine's default — the vertical middle, on the left — where Word's is the top, so a line beside a taller neighbour sits where the page puts it and an amount column stays right-aligned. A cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A cell's lines are one paragraph with line breaks; when its style's `lineSpacing(...)` is above zero and it has more than one line, they are a paragraph each, with the spacing after every line but the last, since Word has no space between the lines of one paragraph but a taller line. 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 | +| 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. Word and LibreOffice give every cell of a row the largest top and bottom margin of any cell in it, so a row's cells are written with its smallest, and the rest of a cell's padding above and below is space above its first paragraph and below its last (measured: a row whose day cells were padded 5.5pt above and 10.25pt below beside a label padded 0.75pt stood 60.3pt tall in both editors, where its tallest cell came to 46). A cell opening with a table has no paragraph above it to hold its padding, and a cell in a vertical merge has its bottom edge in another row: these keep their margins, and the row's comes down no lower than the largest of them. Its padding above and below gives up the room Word makes for the table's horizontal rules — half of a rule between two rows to each, the lower row's rule where the two differ, the rule above the table and the one below it whole to their row — which the page does not (measured: a 0.75pt rule made each row 0.75pt taller). A table that states no rule is written with the engine's default 1pt black rule, as the page draws it, not left on Word's thinner grid. Its `textAnchor` becomes `w:vAlign` and the paragraph's `w:jc`, with the engine's default — the vertical middle, on the left — where Word's is the top, so a line beside a taller neighbour sits where the page puts it and an amount column stays right-aligned. A cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A cell's lines are one paragraph with line breaks; when its style's `lineSpacing(...)` is above zero and it has more than one line, they are a paragraph each, with the spacing after every line but the last, since Word has no space between the lines of one paragraph but a taller line. 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 — a hairline, which the paragraph written next in the cell takes over, so no empty line opens under the table — 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 | @@ -566,9 +566,10 @@ tint it was flattened to. Recorded, like the other two. tile, a disc under a number — anchored to the page where the page draws it. A filled or outlined rectangle or rounded rectangle holding text there is written as a panel is, a table of one cell in its fill and outline, its - outline's width within the cell and a point for the editor's face, with - its layers inside, its corners squared and reported — a rota's shift chips - keep their colour. A badge's glyph — a smaller picture in a filled or outlined + outline's width within the cell and a point for the editor's face, its row + held at least its outline's height, with its layers inside, its corners + squared and reported — a rota's shift chips keep their colour and their + size. A badge's glyph — a smaller picture in a filled or outlined container that clips it to its outline (`CLIP_PATH`) and holds nothing else but drawing — is drawn as a picture anchored to the page over the badge, where the page draws it, rather than written as a line of its own 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 7afddce87..e288d07af 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 @@ -236,6 +236,10 @@ public final class DocxSemanticBackend implements SemanticBackend { // How far each paragraph a container pulled above its cell rises inside its own line: see // riseIntoItsLine. private final java.util.Map risenLines = new java.util.IdentityHashMap<>(); + // How much of the space below a cell's last paragraph is the cell's padding, moved there from + // its margin, in twips: see evenTheRowsMargins. + private final java.util.Map paddingMovedBelow = + new java.util.IdentityHashMap<>(); // The cells of a table's grid that hold a composed node, whose row the table holds at the // page's height: see writeTableWithItsOwnSpacing. private final java.util.Set tablesCells = @@ -612,6 +616,7 @@ private byte[] write(List sections, Path outputFile) throws Exc stackedLineHeights.clear(); lineTopsTakenIn.clear(); risenLines.clear(); + paddingMovedBelow.clear(); tablesCells.clear(); picturesDrawnBeside.clear(); listNumbering.clear(); @@ -2925,8 +2930,7 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container table.getRow(0).setCantSplitRow(true); } if (node instanceof ShapeContainerNode) { - // The page centres a shape's layers in it. The chip is as tall as its line: held to - // the outline's height, every row grew by the cell's own margins round it. + // The page centres a shape's layers in it, in the outline's height the row is held to. cell.setVerticalAlignment(XWPFTableCell.XWPFVertAlign.CENTER); } @@ -2957,6 +2961,12 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container // where the page puts it, a fixed outline — is not in the cell. MerchantInvoice's // due-date card closed from 59.4pt to its text's 26, and its calendar hung below it. holdRowAtLeast(table.getRow(0), placed.placementHeight()); + } else if (first && last && layout.placement(node) == null && node instanceof ShapeContainerNode shape + && shape.outline().height() > 0) { + // Composed in a table cell, it has no placement; its outline states its height, as it + // states its width (panelWidth). CobaltRota's shift chips, 17.5pt outlines round a + // line of text, closed to the text's 13pt in Word. + holdRowAtLeast(table.getRow(0), shape.outline().height()); } if (indent != 0) { @@ -3690,7 +3700,7 @@ private XWPFParagraph dropTheSpaceAtTheEnd(XWPFDocument document) { } /** Leaves out the space below the last line of each cell of a table's last row that shows none of it. */ - private static void dropTheSpaceBelow(XWPFTable table) { + private void dropTheSpaceBelow(XWPFTable table) { if (table.getRows().isEmpty() || drawn(tableBottom(table))) { return; } @@ -3708,7 +3718,14 @@ private static void dropTheSpaceBelow(XWPFTable table) { if (content.get(last) instanceof XWPFParagraph paragraph) { CTPPr lastProperties = paragraph.getCTP().getPPr(); if (lastProperties != null && lastProperties.isSetSpacing() && lastProperties.getSpacing().isSetAfter()) { - lastProperties.getSpacing().unsetAfter(); + // The cell's own padding, moved into its paragraph (evenTheRowsMargins), is + // the cell's and stays: only the space its content held below goes. + Long padding = paddingMovedBelow.get(paragraph.getCTP()); + if (padding != null && padding > 0) { + lastProperties.getSpacing().setAfter(BigInteger.valueOf(padding)); + } else { + lastProperties.getSpacing().unsetAfter(); + } } // A cell cannot end with a table in Word, so one it ends with is followed by // the paragraph that closes it. @@ -6298,6 +6315,110 @@ private void holdRowHeight(XWPFTableRow row, TableNode node, int rowIdx) { } } + /** + * Gives every cell of a row the row's smallest top and bottom margins, the rest of each + * cell's padding written as space above its first paragraph and below its last. + * + *

Word and LibreOffice give every cell of a row the largest top margin of any cell in it, + * and the largest bottom margin: measured, a row whose day cells were padded 5.5pt above and + * 10.25pt below and whose label cell 0.75pt stood 60.3pt tall in both, where its tallest + * cell's content and padding came to 46 — the label's content padded as the day cells were. + * {@code CobaltRota}'s masthead row stood 20.8pt taller than the page's, and each staff row, + * its name padded 2.55pt against its days' 1.35, 2.4pt taller. Space in a cell's paragraphs is + * the cell's own.

+ * + *

Some margins stay as they are, and the row's then comes to the largest of them: a cell + * opening with a table has no paragraph above it to hold its padding, and a cell in a + * vertical merge has its bottom edge in another row. The space moved below a cell's last + * paragraph is its padding, which {@link #dropTheSpaceBelow} keeps.

+ */ + private void evenTheRowsMargins(XWPFTableRow row) { + long top = Long.MAX_VALUE; + long bottom = Long.MAX_VALUE; + long keptTop = 0; + long keptBottom = 0; + for (XWPFTableCell cell : row.getTableCells()) { + top = Math.min(top, cellMargin(cell, true)); + bottom = Math.min(bottom, cellMargin(cell, false)); + if (!canMoveItsPadding(cell, true)) { + keptTop = Math.max(keptTop, cellMargin(cell, true)); + } + if (!canMoveItsPadding(cell, false)) { + keptBottom = Math.max(keptBottom, cellMargin(cell, false)); + } + } + if (top == Long.MAX_VALUE) { + return; + } + top = Math.max(top, keptTop); + bottom = Math.max(bottom, keptBottom); + for (XWPFTableCell cell : row.getTableCells()) { + List content = cell.getBodyElements(); + long extraTop = cellMargin(cell, true) - top; + if (extraTop > 0 && canMoveItsPadding(cell, true)) { + if (!content.isEmpty()) { + addSpacingTwips((XWPFParagraph) content.get(0), extraTop, 0); + } + setCellMarginTwips(cell, true, top); + } + long extraBottom = cellMargin(cell, false) - bottom; + if (extraBottom > 0 && canMoveItsPadding(cell, false)) { + if (!content.isEmpty()) { + XWPFParagraph last = (XWPFParagraph) content.get(content.size() - 1); + addSpacingTwips(last, 0, extraBottom); + paddingMovedBelow.merge(last.getCTP(), extraBottom, Long::sum); + } + setCellMarginTwips(cell, false, bottom); + } + } + } + + /** + * Whether a cell's top or bottom padding can be written in its paragraphs: it has a paragraph + * on that side, and is in no vertical merge. + */ + private static boolean canMoveItsPadding(XWPFTableCell cell, boolean top) { + CTTcPr properties = cell.getCTTc().isSetTcPr() ? cell.getCTTc().getTcPr() : null; + if (properties != null && properties.isSetVMerge()) { + return false; + } + List content = cell.getBodyElements(); + return content.isEmpty() || content.get(top ? 0 : content.size() - 1) instanceof XWPFParagraph; + } + + /** A cell's top or bottom margin as written, in twips; 0 where none is. */ + private static long cellMargin(XWPFTableCell cell, boolean top) { + CTTcPr properties = cell.getCTTc().isSetTcPr() ? cell.getCTTc().getTcPr() : null; + if (properties == null || !properties.isSetTcMar()) { + return 0; + } + CTTcMar margins = properties.getTcMar(); + if (top ? !margins.isSetTop() : !margins.isSetBottom()) { + return 0; + } + return twipsOf((top ? margins.getTop() : margins.getBottom()).getW()); + } + + private static void setCellMarginTwips(XWPFTableCell cell, boolean top, long twips) { + CTTcMar margins = cellProperties(cell).isSetTcMar() ? cellProperties(cell).getTcMar() : cellProperties(cell).addNewTcMar(); + var side = top ? (margins.isSetTop() ? margins.getTop() : margins.addNewTop()) + : (margins.isSetBottom() ? margins.getBottom() : margins.addNewBottom()); + side.setType(STTblWidth.DXA); + side.setW(BigInteger.valueOf(twips)); + } + + /** Adds space above and below a paragraph, in twips, to what it already has. */ + private static void addSpacingTwips(XWPFParagraph para, long before, long after) { + CTPPr properties = para.getCTP().isSetPPr() ? para.getCTP().getPPr() : para.getCTP().addNewPPr(); + CTSpacing spacing = properties.isSetSpacing() ? properties.getSpacing() : properties.addNewSpacing(); + if (before > 0) { + spacing.setBefore(BigInteger.valueOf(twipsOf(spacing.isSetBefore() ? spacing.getBefore() : null) + before)); + } + if (after > 0) { + spacing.setAfter(BigInteger.valueOf(twipsOf(spacing.isSetAfter() ? spacing.getAfter() : null) + after)); + } + } + /** * Whether a row's tallest child on the page, margins included as the row is sized by them, * is one whose cell Word holds nothing in, taller by more than half a point than every child @@ -6457,6 +6578,7 @@ private void writeTableRows(XWPFDocument document, TableNode node) throws Except surfaceBehind = outerSurface; } } + evenTheRowsMargins(row); holdRowHeight(row, node, rowIdx); } breakRowsWhereTheLayoutDoes(table, node); 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 index 9c6d8b6ee..87b2fcfba 100644 --- 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 @@ -174,9 +174,80 @@ void aCellsOwnPaddingBeatsTheRowsAndTheTablesTest() throws Exception { DocumentTableCell.text("cell wins").withStyle( DocumentTableStyle.builder().padding(DocumentInsets.of(11)).build())))); - // The first row's top, less the default 1pt rule above the table. + // The first row's top, less the default 1pt rule above the table: the row's smaller top + // as the cells' margin, the rest of the larger in the cell's first paragraph. assertThat(margins(table.getRow(0).getCell(0))[0]).isEqualTo(Math.round(5 * TWIPS_PER_POINT)); - assertThat(margins(table.getRow(0).getCell(1))[0]).isEqualTo(Math.round(10 * TWIPS_PER_POINT)); + assertThat(margins(table.getRow(0).getCell(1))[0] + before(table.getRow(0).getCell(1))) + .isEqualTo(Math.round(10 * TWIPS_PER_POINT)); + } + + @Test + void aRowsCellsShareItsSmallestVerticalMarginsTheRestWrittenInTheirParagraphs() throws Exception { + // Word and LibreOffice give every cell of a row the row's largest top and bottom margin: + // a cell padded 8pt beside one padded 2pt made the row as tall as if both were padded 8. + XWPFTable table = firstTable(page -> page.addTable(t -> t + .columns(DocumentTableColumn.auto(), DocumentTableColumn.auto()) + .defaultCellStyle(DocumentTableStyle.builder().padding(DocumentInsets.of(2)) + .stroke(DocumentStroke.of(DocumentColor.BLACK, 0)).build()) + .rowCells(DocumentTableCell.text("tight"), + DocumentTableCell.text("loose").withStyle(DocumentTableStyle.builder() + .padding(new DocumentInsets(8, 2, 6, 2)).build())))); + XWPFTableCell tight = table.getRow(0).getCell(0); + XWPFTableCell loose = table.getRow(0).getCell(1); + + assertThat(margins(loose)[0]).as("top, as the tight cell's").isEqualTo(margins(tight)[0]); + assertThat(margins(loose)[2]).as("bottom, as the tight cell's").isEqualTo(margins(tight)[2]); + assertThat(margins(loose)[0] + before(loose)).as("its own 8pt above").isEqualTo(Math.round(8 * TWIPS_PER_POINT)); + assertThat(margins(loose)[2] + after(loose)).as("its own 6pt below").isEqualTo(Math.round(6 * TWIPS_PER_POINT)); + } + + @Test + void aCellOpeningWithATableKeepsItsMarginAndTheRowComesToIt() throws Exception { + // The nested table has no paragraph above it to hold its cell's 5pt: the row's top comes + // to 5pt in Word, and the 3pt cell beside it is not given its padding twice. + XWPFTable table = firstTable(page -> page.addTable(t -> t + .columns(DocumentTableColumn.auto(), DocumentTableColumn.auto(), DocumentTableColumn.auto()) + .defaultCellStyle(DocumentTableStyle.builder().padding(DocumentInsets.of(2)) + .stroke(DocumentStroke.of(DocumentColor.BLACK, 0)).build()) + .rowCells(DocumentTableCell.text("tight"), + DocumentTableCell.text("three").withStyle(DocumentTableStyle.builder() + .padding(DocumentInsets.of(3)).build()), + DocumentTableCell.node(new com.demcha.compose.document.dsl.TableBuilder() + .columns(DocumentTableColumn.auto()).row("inner").build()) + .withStyle(DocumentTableStyle.builder().padding(DocumentInsets.of(5)).build())))); + XWPFTableCell three = table.getRow(0).getCell(1); + + assertThat(table.getRow(0).getCell(2).getBodyElements().get(0)).isInstanceOf(XWPFTable.class); + assertThat(margins(three)[0]).as("left as it was").isEqualTo(Math.round(3 * TWIPS_PER_POINT)); + assertThat(before(three)).as("no padding written twice").isZero(); + } + + @Test + void aCellInAVerticalMergeKeepsItsMargins() throws Exception { + // Its bottom edge is in the last row it spans, not in the row it starts. + XWPFTable table = firstTable(page -> page.addTable(t -> t + .columns(DocumentTableColumn.auto(), DocumentTableColumn.auto()) + .defaultCellStyle(DocumentTableStyle.builder().padding(DocumentInsets.of(2)) + .stroke(DocumentStroke.of(DocumentColor.BLACK, 0)).build()) + .rowCells(DocumentTableCell.text("spans").rowSpan(2).withStyle(DocumentTableStyle.builder() + .padding(new DocumentInsets(2, 2, 10, 2)).build()), + DocumentTableCell.text("first")) + .rowCells(DocumentTableCell.text("second")))); + XWPFTableCell spans = table.getRow(0).getCell(0); + + assertThat(margins(spans)[2]).as("its own bottom").isEqualTo(Math.round(10 * TWIPS_PER_POINT)); + assertThat(after(spans)).as("none written below it").isZero(); + } + + private static long before(XWPFTableCell cell) { + var spacing = cell.getParagraphs().get(0).getCTP().getPPr().getSpacing(); + return spacing != null && spacing.isSetBefore() ? DocxTwips.of(spacing.getBefore()) : 0; + } + + private static long after(XWPFTableCell cell) { + var paragraphs = cell.getParagraphs(); + var spacing = paragraphs.get(paragraphs.size() - 1).getCTP().getPPr().getSpacing(); + return spacing != null && spacing.isSetAfter() ? DocxTwips.of(spacing.getAfter()) : 0; } @Test diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellTest.java index 0f09a0162..7537304d2 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellTest.java @@ -166,8 +166,10 @@ void aFilledPillInACellIsAPanelWithItsTextInside() throws Exception { .as("as wide as its outline, and a point for the editor's face") .isEqualTo(61L * 20); assertThat(chip.getVerticalAlignment()).isEqualTo(XWPFTableCell.XWPFVertAlign.CENTER); - assertThat(cell.getTables().get(0).getRow(0).getHeight()).as("as tall as its line, not held to its outline") - .isZero(); + // Held to its outline's 14pt, as the page draws it: left to its line, CobaltRota's 17.5pt + // chips closed to 13pt round their hours. + assertThat(cell.getTables().get(0).getRow(0).getHeight()).as("held to its outline") + .isEqualTo(14 * 20); } @Test From 8108d7f56c669d689b23ee5d05cd8e8903361ae8 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Wed, 30 Sep 2026 21:55:25 +0100 Subject: [PATCH 2/2] fix(docx): pin the bottom floor of an evened row, and drop the end-of-page guard no table reaches --- CHANGELOG.md | 4 +- .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 2 +- .../semantic/docx/DocxSemanticBackend.java | 37 +++++++------------ .../semantic/docx/DocxCellPaddingTest.java | 25 ++++++++++++- .../semantic/docx/DocxComposedCellTest.java | 3 ++ 6 files changed, 44 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ff941d20..2c5df42cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ follow semantic versioning; release dates are ISO 8601. ### Public API -- **A table row whose cells are padded differently is as tall in Word as on the page.** Word and +- **A table row's cells keep their own vertical padding in Word, not the row's largest.** Word and LibreOffice give every cell of a row the largest top and bottom margin of any cell in it: measured, a row whose day cells were padded 5.5pt above and 10.25pt below beside a label padded 0.75pt stood 60.3pt tall in both, where its tallest cell came to 46. `CobaltRota`'s @@ -18,7 +18,7 @@ follow semantic versioning; release dates are ISO 8601. opening with a table, or in a vertical merge, keeps its margins, and the row's comes down no lower than the largest of them. A shape composed in a table cell and written as a panel — a rota's shift chip — is held to its - outline's height, where it had closed round its line of text: 13pt for a 17.5pt chip. In both + outline's height, where it had closed round its line of text: most of the 17.5pt chips to 12.7pt. In both editors `CobaltRota`'s median drift falls from 23.1pt to 8.9, and it fits one page again in LibreOffice. diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 0e75c4479..f309de955 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -79,7 +79,7 @@ Payload records live in `core` under | Gradient strokes | ✅ `PdfPathPainter` (pattern stroking colour) | ✅ `PptxGradientFill` (native `ln`/`gradFill`) | ❌ | | Image — STRETCH / CONTAIN / COVER fit (`ImageFragmentPayload`) | ✅ `PdfImageFragmentRenderHandler` | ✅ `PptxImageFragmentRenderHandler` (COVER via the picture source crop) | ✅ `DocxSemanticBackend.writeImage` (the box comes from `NodeDefinitionSupport.resolveImageDimensions`, the same rule layout applies to `width` / `height` / `scale` and the content-width clamp; CONTAIN is embedded at its fitted size, COVER via the picture source crop as in PPTX, and the picture type is read from the bytes) | | Barcode / QR (`BarcodeFragmentPayload`) | ✅ `PdfBarcodeFragmentRenderHandler` (vector: the ZXing bit matrix filled as merged rectangles) | ✅ `PptxBarcodeFragmentRenderHandler` (native freeforms: the same ZXing bit matrix as merged rectangles) | ⚠️ `DocxSemanticBackend.writeBarcode` (a PNG picture of the same ZXing bit matrix through `BarcodeMatrices`, one pixel a cell, in the symbol's two colours with their alpha and at the node's size, its data as the picture's description; it scans, but its data is part of the picture rather than editable, reported `APPROXIMATED`, which also names a link or a transform on it as not carried; an `anchor` is a bookmark on its paragraph; in a page zone it is skipped) | -| Table rows — resolved cells, row/col spans, two-pass fill/border paint (`TableRowFragmentPayload`) | ✅ `PdfTableRowFragmentRenderHandler` + row grouping in `PdfFixedLayoutBackend` | ✅ `PptxTableRowFragmentRenderHandler` + row grouping in `PptxFixedLayoutBackend` (positioned rectangles, edge lines, and text frames — never native PPTX tables, which re-lay-out content) | ⚠️ `DocxSemanticBackend.writeTable` (a real Word table on the grid `TableGrid` resolves: `colSpan` maps to `w:gridSpan`, `rowSpan` to `w:vMerge`, and the cascaded `DocumentTableStyle` text style reaches the cell's runs; the cell's fill maps to `w:shd` and its stroke to `w:tcBorders` — the engine's default 1pt black rule where the table states none, not Word's thinner grid — its padding to `w:tcMar`, less above and below the room Word makes for the horizontal rules (half of a rule between two rows, the lower row's, to each; the rules above and below the table whole to their row); the cascaded `textAnchor` maps to `w:vAlign` on every cell and to `w:jc` on a text cell's paragraph, with the engine's default — the vertical middle, on the left, or on the right for a right-to-left cell — and `DEFAULT` at the bottom left, as the renderer draws it; a composed cell is written by the same writers that write its node anywhere, so one built from an image, a list or a table carries it — a nested table is a real `w:tbl` taking the width of the column it sits in, which is the column's rather than the one the page gives it, since the layout reports a composed cell's content under the owner's path; a fill's opacity is dropped since `w:shd` is opaque; Word re-paginates, so the export states where the layout breaks: every row the layout placed is `w:cantSplit`, `repeatHeader(n)` rows are `w:tblHeader` and keep with the row under them, and a row of blocks is kept whole the same way) | +| Table rows — resolved cells, row/col spans, two-pass fill/border paint (`TableRowFragmentPayload`) | ✅ `PdfTableRowFragmentRenderHandler` + row grouping in `PdfFixedLayoutBackend` | ✅ `PptxTableRowFragmentRenderHandler` + row grouping in `PptxFixedLayoutBackend` (positioned rectangles, edge lines, and text frames — never native PPTX tables, which re-lay-out content) | ⚠️ `DocxSemanticBackend.writeTable` (a real Word table on the grid `TableGrid` resolves: `colSpan` maps to `w:gridSpan`, `rowSpan` to `w:vMerge`, and the cascaded `DocumentTableStyle` text style reaches the cell's runs; the cell's fill maps to `w:shd` and its stroke to `w:tcBorders` — the engine's default 1pt black rule where the table states none, not Word's thinner grid — its padding to `w:tcMar`, less above and below the room Word makes for the horizontal rules (half of a rule between two rows, the lower row's, to each; the rules above and below the table whole to their row); a row's cells at the row's smallest top and bottom margins, since both editors give every cell the row's largest, the rest of each cell's padding as space above its first paragraph and below its last, down to the largest margin a cell opening with a table or in a vertical merge keeps; the cascaded `textAnchor` maps to `w:vAlign` on every cell and to `w:jc` on a text cell's paragraph, with the engine's default — the vertical middle, on the left, or on the right for a right-to-left cell — and `DEFAULT` at the bottom left, as the renderer draws it; a composed cell is written by the same writers that write its node anywhere, so one built from an image, a list or a table carries it — a nested table is a real `w:tbl` taking the width of the column it sits in, which is the column's rather than the one the page gives it, since the layout reports a composed cell's content under the owner's path; a fill's opacity is dropped since `w:shd` is opaque; Word re-paginates, so the export states where the layout breaks: every row the layout placed is `w:cantSplit`, `repeatHeader(n)` rows are `w:tblHeader` and keep with the row under them, and a row of blocks is kept whole the same way) | | Clip region open/close (`ShapeClipBegin/EndPayload`) | ✅ `PdfShapeClipBegin/EndRenderHandler` (CLIP_BOUNDS + CLIP_PATH) | ✅ `PptxClipSafety` + raster fallback in `PptxFixedLayoutBackend` — a provably no-op clip (padded content that cannot be cut) skips the fallback entirely and stays native, editable shapes; a clip that can cut ink renders through the PDF backend into one transparent picture on the clip bounds (pixel-exact, not editable as shapes; run-level link hotspots are not emitted and custom fragment handlers do not apply inside the picture; `Builder.clipRasterFallback(false)` restores unclipped vectors + warning; the raster targets a 2048px long edge, clamped to between native size and 4x, so a region larger than that is rendered at native resolution rather than downscaled — which also means its transient memory grows with the clip instead of stopping at the target (a 3370pt A0-landscape region costs ~45MB while rendering, against ~17MB for anything up to 2048pt); a true vector clip is tracked in [#413](https://github.com/DemchaAV/GraphCompose/issues/413)) | ⚠️ inline fallback + one-time capability warning; a picture that fills a container clipped to an ellipse takes the ellipse as its geometry, which both editors crop it to; a badge's glyph — a smaller picture in a painted container that clips it to its outline (`CLIP_PATH`) and holds nothing else but drawing — is drawn by `DocxDrawings` as a picture anchored to the page over the outline, where the layout places it, reported `APPROXIMATED` — inside a filled panel the badge and its glyph are drawn in front of the shading; an icon picture beside its text in an unpainted container or a layer stack is drawn the same way; a filled or outlined rectangle or rounded rectangle holding text, composed in a table cell, which has no place in the layout to be drawn at, is written as a panel — a one-cell table in its fill and outline, its corners squared and reported; the rest of what a composed cell draws (an icon, a tile, a disc) is the table's own drawing and is drawn by `drawCellDrawing`, anchored to the page where the layout puts it | | Timeline rail — one logical connector line resolved from marker and entry anchors after layout (`ShapeFragmentPayload` per page) | ✅ `PdfShapeFragmentRenderHandler` — one fragment per page, spliced beneath the markers | ✅ `PptxShapeFragmentRenderHandler` — same payload, same per-page fragments | ⚠️ `DocxDrawings` — the rail is read from the resolved layout's pass fragments and drawn per page as a `line` shape anchored to the page, and the markers as the shapes they are; they stay where the layout put them when the entries' text is edited | | Transform open/close — rotate/scale about fragment centre (`TransformBegin/EndPayload`) | ✅ `PdfTransformBegin/EndRenderHandler` | ✅ `PptxTransformBegin/EndRenderHandler` (group shape; rotation and centre-pivot scaling via the exterior/interior frame ratio) | ⚠️ inline fallback + one-time capability warning | diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 2522fa0a7..7f4d0dfbd 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -69,7 +69,7 @@ creation date is real metadata. |---|---| | Paragraphs | Word paragraphs with alignment, font, size, colour, bold/italic/underline; inline runs preserved; a `\n` in the text is a line break (`w:br`), which Word would otherwise read as a space; a table cell's `text("a\nb")` stays one line, as the page sets it | | 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. Word and LibreOffice give every cell of a row the largest top and bottom margin of any cell in it, so a row's cells are written with its smallest, and the rest of a cell's padding above and below is space above its first paragraph and below its last (measured: a row whose day cells were padded 5.5pt above and 10.25pt below beside a label padded 0.75pt stood 60.3pt tall in both editors, where its tallest cell came to 46). A cell opening with a table has no paragraph above it to hold its padding, and a cell in a vertical merge has its bottom edge in another row: these keep their margins, and the row's comes down no lower than the largest of them. Its padding above and below gives up the room Word makes for the table's horizontal rules — half of a rule between two rows to each, the lower row's rule where the two differ, the rule above the table and the one below it whole to their row — which the page does not (measured: a 0.75pt rule made each row 0.75pt taller). A table that states no rule is written with the engine's default 1pt black rule, as the page draws it, not left on Word's thinner grid. Its `textAnchor` becomes `w:vAlign` and the paragraph's `w:jc`, with the engine's default — the vertical middle, on the left — where Word's is the top, so a line beside a taller neighbour sits where the page puts it and an amount column stays right-aligned. A cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A cell's lines are one paragraph with line breaks; when its style's `lineSpacing(...)` is above zero and it has more than one line, they are a paragraph each, with the spacing after every line but the last, since Word has no space between the lines of one paragraph but a taller line. 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 | +| Tables | Word tables, one cell per cell. Each cell states its own padding, on all four sides, so a row is as tall as the page draws it: as `w:tcMar`, and above and below partly in its paragraphs. Word and LibreOffice give every cell of a row the largest top and bottom margin of any cell in it, so a row's cells are written with its smallest, and the rest of a cell's padding above and below is space above its first paragraph and below its last (measured: a row whose day cells were padded 5.5pt above and 10.25pt below beside a label padded 0.75pt stood 60.3pt tall in both editors, where its tallest cell came to 46). A cell opening with a table has no paragraph above it to hold its padding, and a cell in a vertical merge has its bottom edge in another row: these keep their margins, and the row's comes down no lower than the largest of them. Its padding above and below gives up the room Word makes for the table's horizontal rules — half of a rule between two rows to each, the lower row's rule where the two differ, the rule above the table and the one below it whole to their row — which the page does not (measured: a 0.75pt rule made each row 0.75pt taller). A table that states no rule is written with the engine's default 1pt black rule, as the page draws it, not left on Word's thinner grid. Its `textAnchor` becomes `w:vAlign` and the paragraph's `w:jc`, with the engine's default — the vertical middle, on the left — where Word's is the top, so a line beside a taller neighbour sits where the page puts it and an amount column stays right-aligned. A cell with no style of its own is set in the engine's default cell face rather than the document's Normal. A cell's lines are one paragraph with line breaks; when its style's `lineSpacing(...)` is above zero and it has more than one line, they are a paragraph each, with the spacing after every line but the last, since Word has no space between the lines of one paragraph but a taller line. 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 — a hairline, which the paragraph written next in the cell takes over, so no empty line opens under the table — 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 e288d07af..2b858a2bd 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 @@ -236,10 +236,6 @@ public final class DocxSemanticBackend implements SemanticBackend { // How far each paragraph a container pulled above its cell rises inside its own line: see // riseIntoItsLine. private final java.util.Map risenLines = new java.util.IdentityHashMap<>(); - // How much of the space below a cell's last paragraph is the cell's padding, moved there from - // its margin, in twips: see evenTheRowsMargins. - private final java.util.Map paddingMovedBelow = - new java.util.IdentityHashMap<>(); // The cells of a table's grid that hold a composed node, whose row the table holds at the // page's height: see writeTableWithItsOwnSpacing. private final java.util.Set tablesCells = @@ -616,7 +612,6 @@ private byte[] write(List sections, Path outputFile) throws Exc stackedLineHeights.clear(); lineTopsTakenIn.clear(); risenLines.clear(); - paddingMovedBelow.clear(); tablesCells.clear(); picturesDrawnBeside.clear(); listNumbering.clear(); @@ -2965,7 +2960,7 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container && shape.outline().height() > 0) { // Composed in a table cell, it has no placement; its outline states its height, as it // states its width (panelWidth). CobaltRota's shift chips, 17.5pt outlines round a - // line of text, closed to the text's 13pt in Word. + // line of text, closed to the text's 12.7pt in Word. holdRowAtLeast(table.getRow(0), shape.outline().height()); } @@ -3700,7 +3695,7 @@ private XWPFParagraph dropTheSpaceAtTheEnd(XWPFDocument document) { } /** Leaves out the space below the last line of each cell of a table's last row that shows none of it. */ - private void dropTheSpaceBelow(XWPFTable table) { + private static void dropTheSpaceBelow(XWPFTable table) { if (table.getRows().isEmpty() || drawn(tableBottom(table))) { return; } @@ -3718,14 +3713,7 @@ private void dropTheSpaceBelow(XWPFTable table) { if (content.get(last) instanceof XWPFParagraph paragraph) { CTPPr lastProperties = paragraph.getCTP().getPPr(); if (lastProperties != null && lastProperties.isSetSpacing() && lastProperties.getSpacing().isSetAfter()) { - // The cell's own padding, moved into its paragraph (evenTheRowsMargins), is - // the cell's and stays: only the space its content held below goes. - Long padding = paddingMovedBelow.get(paragraph.getCTP()); - if (padding != null && padding > 0) { - lastProperties.getSpacing().setAfter(BigInteger.valueOf(padding)); - } else { - lastProperties.getSpacing().unsetAfter(); - } + lastProperties.getSpacing().unsetAfter(); } // A cell cannot end with a table in Word, so one it ends with is followed by // the paragraph that closes it. @@ -6329,10 +6317,11 @@ private void holdRowHeight(XWPFTableRow row, TableNode node, int rowIdx) { * *

Some margins stay as they are, and the row's then comes to the largest of them: a cell * opening with a table has no paragraph above it to hold its padding, and a cell in a - * vertical merge has its bottom edge in another row. The space moved below a cell's last - * paragraph is its padding, which {@link #dropTheSpaceBelow} keeps.

+ * vertical merge spans rows whose margins are evened apart. A table the page ends with + * keeps the space moved below its cells' text: {@link #dropTheSpaceBelow} leaves a table + * with a drawn bottom alone, and every table written here has one.

*/ - private void evenTheRowsMargins(XWPFTableRow row) { + private static void evenTheRowsMargins(XWPFTableRow row) { long top = Long.MAX_VALUE; long bottom = Long.MAX_VALUE; long keptTop = 0; @@ -6364,9 +6353,7 @@ private void evenTheRowsMargins(XWPFTableRow row) { long extraBottom = cellMargin(cell, false) - bottom; if (extraBottom > 0 && canMoveItsPadding(cell, false)) { if (!content.isEmpty()) { - XWPFParagraph last = (XWPFParagraph) content.get(content.size() - 1); - addSpacingTwips(last, 0, extraBottom); - paddingMovedBelow.merge(last.getCTP(), extraBottom, Long::sum); + addSpacingTwips((XWPFParagraph) content.get(content.size() - 1), 0, extraBottom); } setCellMarginTwips(cell, false, bottom); } @@ -6374,8 +6361,9 @@ private void evenTheRowsMargins(XWPFTableRow row) { } /** - * Whether a cell's top or bottom padding can be written in its paragraphs: it has a paragraph - * on that side, and is in no vertical merge. + * Whether a cell's top or bottom padding can be written in its paragraphs: it is in no + * vertical merge, and a paragraph opens (or closes) it — a cell written here always holds + * one, a table in it closed by a paragraph after it. */ private static boolean canMoveItsPadding(XWPFTableCell cell, boolean top) { CTTcPr properties = cell.getCTTc().isSetTcPr() ? cell.getCTTc().getTcPr() : null; @@ -6400,7 +6388,8 @@ private static long cellMargin(XWPFTableCell cell, boolean top) { } private static void setCellMarginTwips(XWPFTableCell cell, boolean top, long twips) { - CTTcMar margins = cellProperties(cell).isSetTcMar() ? cellProperties(cell).getTcMar() : cellProperties(cell).addNewTcMar(); + CTTcPr properties = cellProperties(cell); + CTTcMar margins = properties.isSetTcMar() ? properties.getTcMar() : properties.addNewTcMar(); var side = top ? (margins.isSetTop() ? margins.getTop() : margins.addNewTop()) : (margins.isSetBottom() ? margins.getBottom() : margins.addNewBottom()); side.setType(STTblWidth.DXA); 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 index 87b2fcfba..61a323873 100644 --- 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 @@ -218,13 +218,35 @@ void aCellOpeningWithATableKeepsItsMarginAndTheRowComesToIt() throws Exception { XWPFTableCell three = table.getRow(0).getCell(1); assertThat(table.getRow(0).getCell(2).getBodyElements().get(0)).isInstanceOf(XWPFTable.class); + assertThat(margins(table.getRow(0).getCell(2))[0]).as("the nested table's cell keeps its own") + .isEqualTo(Math.round(5 * TWIPS_PER_POINT)); assertThat(margins(three)[0]).as("left as it was").isEqualTo(Math.round(3 * TWIPS_PER_POINT)); assertThat(before(three)).as("no padding written twice").isZero(); } + @Test + void aCellInAVerticalMergeHoldsTheRowsBottomUpToItsOwn() throws Exception { + // The merged cell keeps its 10pt below; a 6pt cell beside it in the row is left as it + // was, not lowered to the 2pt cell's and given its padding in its paragraph as well. + XWPFTable table = firstTable(page -> page.addTable(t -> t + .columns(DocumentTableColumn.auto(), DocumentTableColumn.auto(), DocumentTableColumn.auto()) + .defaultCellStyle(DocumentTableStyle.builder().padding(DocumentInsets.of(2)) + .stroke(DocumentStroke.of(DocumentColor.BLACK, 0)).build()) + .rowCells(DocumentTableCell.text("spans").rowSpan(2).withStyle(DocumentTableStyle.builder() + .padding(new DocumentInsets(2, 2, 10, 2)).build()), + DocumentTableCell.text("six").withStyle(DocumentTableStyle.builder() + .padding(new DocumentInsets(2, 2, 6, 2)).build()), + DocumentTableCell.text("two")) + .rowCells(DocumentTableCell.text("second"), DocumentTableCell.text("third")))); + XWPFTableCell six = table.getRow(0).getCell(1); + + assertThat(margins(six)[2]).as("left as it was").isEqualTo(Math.round(6 * TWIPS_PER_POINT)); + assertThat(after(six)).as("no padding written twice").isZero(); + } + @Test void aCellInAVerticalMergeKeepsItsMargins() throws Exception { - // Its bottom edge is in the last row it spans, not in the row it starts. + // It spans rows whose margins are evened apart: its bottom edge is in the last row. XWPFTable table = firstTable(page -> page.addTable(t -> t .columns(DocumentTableColumn.auto(), DocumentTableColumn.auto()) .defaultCellStyle(DocumentTableStyle.builder().padding(DocumentInsets.of(2)) @@ -235,6 +257,7 @@ void aCellInAVerticalMergeKeepsItsMargins() throws Exception { .rowCells(DocumentTableCell.text("second")))); XWPFTableCell spans = table.getRow(0).getCell(0); + assertThat(margins(spans)[0]).as("its own top").isEqualTo(Math.round(2 * TWIPS_PER_POINT)); assertThat(margins(spans)[2]).as("its own bottom").isEqualTo(Math.round(10 * TWIPS_PER_POINT)); assertThat(after(spans)).as("none written below it").isZero(); } diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellTest.java index 7537304d2..f843d02f3 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxComposedCellTest.java @@ -170,6 +170,9 @@ void aFilledPillInACellIsAPanelWithItsTextInside() throws Exception { // chips closed to 13pt round their hours. assertThat(cell.getTables().get(0).getRow(0).getHeight()).as("held to its outline") .isEqualTo(14 * 20); + assertThat(cell.getTables().get(0).getRow(0).getCtRow().getTrPr().getTrHeightArray(0).getHRule()) + .as("at least: a longer label still grows it") + .isEqualTo(org.openxmlformats.schemas.wordprocessingml.x2006.main.STHeightRule.AT_LEAST); } @Test