From 0b7ae39a38c881c6d9c2bc42e4d415336005038a Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Wed, 30 Sep 2026 14:54:03 +0100 Subject: [PATCH 1/2] fix(docx): end a bordered panel's right border inside its cell Word draws a table's right border outside its right edge and, on screen, cuts off what passes its cell's edge and draws the cell's gridline there. A bordered panel reaching its cell's text edge now ends its right border and half a point short of it, the points taken from its right margin and no more than that margin holds, so its text keeps its place and width. --- CHANGELOG.md | 8 +++ docs/recipes/docx-export.md | 6 +- .../semantic/docx/DocxSemanticBackend.java | 37 +++++++++-- .../semantic/docx/DocxPanelEdgeCasesTest.java | 61 +++++++++++++++++++ 4 files changed, 107 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c4ea8a44..2efe9d294 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A bordered panel in a table cell shows its right border in Word.** Word draws a table's right + border outside its right edge — measured in its PDF, a table ending at 566.0pt had its right + border from 566.2 to 566.9 — and on screen cuts off what passes its cell's edge and draws the + cell's gridline there. `EditorialProposal`'s glance card, as wide as its column, lost its right + border on screen. A bordered panel reaching its cell's text edge now ends its right border and + half a point short of it, the points taken from its right margin and no more than that margin + holds: its text stands where it did and wraps as it did, and its right border is drawn on + screen and on paper wherever its margin is wide enough. - **A ruled table's rows are as tall in Word as on the page, and a table that states no rule is ruled as the page rules it.** Word makes room in a table for its horizontal rules — a rule between two rows half in each, the rule above the table and the one below it whole in their diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 6f67d415b..ae2057653 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -351,7 +351,11 @@ How it lands: text of a card land within a pixel of the page's. - **Nesting.** A panel inside a panel is a table inside its cell. So is a row, with no fill of its own, so the panel shows through it. A table keeps its own cell fills, and a cell - no style fills is written white, as the page draws it on the card. + no style fills is written white, as the page draws it on the card. Word draws a table's + right border outside its right edge and, on screen, cuts off what passes its cell's edge + and draws the cell's gridline there; a bordered panel reaching its cell's text edge ends its + right border and half a point short of it, the points taken from its right margin and no more + than it holds, so its text keeps its place and width. - **Keeping together.** A `keepTogether()` panel the layout placed on one page is a row Word may not split. Anchors and keeps on the blocks inside a panel carry as they do anywhere else. 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 e76d6e50f..28190a583 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 @@ -2840,6 +2840,12 @@ private void writePanel(XWPFDocument document, DocumentNode node, ContainerPaint } } + /** + * The room a panel in a cell leaves past its right border, in points: Word draws the cell's + * gridline on screen at the cell's edge, over a border that meets it. + */ + private static final double CLEAR_OF_THE_GRIDLINE_POINTS = 0.5; + /** Writes one table of a panel: the whole panel, or the part of it between page breaks. */ private void writePanelPiece(XWPFDocument document, DocumentNode node, ContainerPaint paint, List children, boolean first, boolean last) throws Exception { @@ -2863,11 +2869,33 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container holdTheSpaceAboveATable(document); double width = panelWidth(node); + double edge = insetLeft + margin.left(); + double indent = currentCell == null ? edge + padding.left() - halfLeft : edge - halfLeft; + // In a cell, Word starts a nested table no further left than the cell's text, draws its + // right border outside the table's right edge — measured in its PDF, a table ending at + // 566.0pt had its right border from 566.2 to 566.9 — and on screen cuts off whatever + // passes the cell's edge and draws the cell's gridline over it: a panel as wide as its + // cell lost its right border, EditorialProposal's glance card. A panel with a right + // border that reaches its cell's text edge ends that border and a little more short of + // it, the points taken from its right margin and no more than it holds, so its text keeps + // its place and its width. + double shortOfTheEdge = 0; + double room = currentCell != null && Double.isFinite(currentCellWidth) && strokeWidth(borders.right()) > 0 + ? currentCellWidth - insetRight - Math.max(0, indent) - strokeWidth(borders.right()) + - CLEAR_OF_THE_GRIDLINE_POINTS + : Double.NaN; + XWPFTable table = newTable(document, 1, 1); hideTableGrid(table); XWPFTableCell cell = table.getRow(0).getCell(0); if (Double.isFinite(width) && width > 0) { double outer = width + halfLeft + halfRight; + if (room > 0 && outer > room) { + // No more than the right margin holds: past that the text would narrow and wrap, + // which is worse than a border the cell's edge covers. + shortOfTheEdge = Math.min(outer - room, insideTheBorders(padding, borders).right()); + outer -= shortOfTheEdge; + } setTableWidth(table, outer); writeGrid(table, new double[]{outer}); CTTcPr properties = cellProperties(cell); @@ -2877,7 +2905,9 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container } applyCellPaint(cell, paint.fill(), null); paintCellSides(cell, paint.borders()); - applyCellPadding(cell, insideTheBorders(padding, borders)); + DocumentInsets margins = insideTheBorders(padding, borders); + applyCellPadding(cell, new DocumentInsets(margins.top(), Math.max(0, margins.right() - shortOfTheEdge), + margins.bottom(), margins.left())); if (node.keepTogether() && layout.onOnePage(node)) { table.getRow(0).setCantSplitRow(true); } @@ -2916,15 +2946,14 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container holdRowAtLeast(table.getRow(0), placed.placementHeight()); } - double edge = insetLeft + margin.left(); - double indent = currentCell == null ? edge + padding.left() - halfLeft : edge - halfLeft; if (indent != 0) { CTTblPr tableProperties = table.getCTTbl().getTblPr(); CTTblWidth tableIndent = tableProperties.isSetTblInd() ? tableProperties.getTblInd() : tableProperties.addNewTblInd(); tableIndent.setType(STTblWidth.DXA); - // Signed: a nested panel with no margin starts half its border left of the cell. + // Signed: a nested panel with no margin starts half its border left of the cell, and + // Word starts it at the cell's text. tableIndent.setW(BigInteger.valueOf(Math.round(indent * POINT_TO_TWIP))); } if (last) { diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelEdgeCasesTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelEdgeCasesTest.java index 308b2e6b2..c55e42882 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelEdgeCasesTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelEdgeCasesTest.java @@ -162,6 +162,67 @@ void aNestedCardWithNoMarginStartsHalfItsBorderLeftOfTheCell() throws Exception } } + @Test + void aNestedCardWithTooThinAMarginGivesUpNoMoreThanItHolds() throws Exception { + // 1pt of padding against a 1.5pt border: the margin can give back 0.25pt, not the 2.25 + // the border would need. The text keeps its width; the border stays where it was. + try (XWPFDocument document = export(page -> page.addSection("Outer", outer -> outer + .fillColor(SURFACE) + .addSection("Inner", inner -> inner + .stroke(com.demcha.compose.document.style.DocumentStroke.of(ACCENT, 1.5)) + .padding(DocumentInsets.of(1)) + .addParagraph(p -> p.text("Inner")))))) { + XWPFTableCell host = card(document); + var hostProperties = host.getCTTc().getTcPr(); + long room = DocxTwips.of(hostProperties.getTcW().getW()) + - DocxTwips.of(hostProperties.getTcMar().getLeft().getW()) + - DocxTwips.of(hostProperties.getTcMar().getRight().getW()); + XWPFTable nested = host.getTables().get(0); + long width = DocxTwips.of(nested.getCTTbl().getTblPr().getTblW().getW()); + var margins = nested.getRow(0).getCell(0).getCTTc().getTcPr().getTcMar(); + + assertThat(DocxTwips.of(margins.getRight().getW())).as("all its right margin given up").isZero(); + // As wide as its cell, every border inside its margins: the room less 1pt of padding + // each side, and the four half borders the margins give back. + assertThat(width - DocxTwips.of(margins.getLeft().getW()) - DocxTwips.of(margins.getRight().getW())) + .as("its text as wide as on the page") + .isCloseTo(room - 40 + 60, org.assertj.core.data.Offset.offset(3L)); + } + } + + @Test + void aNestedCardAsWideAsItsCellEndsItsRightBorderInsideItAndKeepsItsTextsWidth() throws Exception { + // Word starts a nested table no further left than its cell's text, centres its borders on + // its edges and, on screen, cuts off what passes the cell's right edge: a card as wide as + // its cell lost its right border. The points it gives up come off its right margin. + try (XWPFDocument document = export(page -> page.addSection("Outer", outer -> outer + .fillColor(SURFACE) + .addSection("Inner", inner -> inner + .stroke(com.demcha.compose.document.style.DocumentStroke.of(ACCENT, 1.5)) + .padding(DocumentInsets.of(10)) + .addParagraph(p -> p.text("Inner")))))) { + XWPFTableCell host = card(document); + XWPFTable nested = host.getTables().get(0); + var hostProperties = host.getCTTc().getTcPr(); + long room = DocxTwips.of(hostProperties.getTcW().getW()) + - DocxTwips.of(hostProperties.getTcMar().getLeft().getW()) + - DocxTwips.of(hostProperties.getTcMar().getRight().getW()); + var tableProperties = nested.getCTTbl().getTblPr(); + long start = Math.max(0, tableProperties.isSetTblInd() ? DocxTwips.of(tableProperties.getTblInd().getW()) : 0); + long width = DocxTwips.of(tableProperties.getTblW().getW()); + var margins = nested.getRow(0).getCell(0).getCTTc().getTcPr().getTcMar(); + long text = width - DocxTwips.of(margins.getLeft().getW()) - DocxTwips.of(margins.getRight().getW()); + + // Word draws the right border, 1.5pt or 30 twips, outside the table's edge. The text + // is as wide as the card written as wide as its cell, every border inside the cell's + // margins: its room less its 10pt padding each side, and the four half borders the + // margins give back. + assertThat(start + width + 30).as("its right border inside the cell").isLessThanOrEqualTo(room); + assertThat(text).as("its text as wide as on the page") + .isCloseTo(room - 400 + 60, org.assertj.core.data.Offset.offset(3L)); + } + } + @Test void aCardWithNoCanvasLeavesItsWidthToTheEditor() throws Exception { try (XWPFDocument document = DocxExports.withoutCanvas(page -> page.addSection("Card", card -> card From 1dae1612004849135ace81b79c99413ce37eb719 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Wed, 30 Sep 2026 15:09:16 +0100 Subject: [PATCH 2/2] test(docx): pin a panel's right border clear of its cell's gridline The containment test now includes the half point left for the gridline, and the wording of the change reads as its whole right border plus half a point short of the cell's text edge. --- CHANGELOG.md | 8 +++---- docs/recipes/docx-export.md | 4 ++-- .../semantic/docx/DocxPanelEdgeCasesTest.java | 23 +++++++++++-------- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2efe9d294..2fb98dffa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,10 @@ follow semantic versioning; release dates are ISO 8601. border outside its right edge — measured in its PDF, a table ending at 566.0pt had its right border from 566.2 to 566.9 — and on screen cuts off what passes its cell's edge and draws the cell's gridline there. `EditorialProposal`'s glance card, as wide as its column, lost its right - border on screen. A bordered panel reaching its cell's text edge now ends its right border and - half a point short of it, the points taken from its right margin and no more than that margin - holds: its text stands where it did and wraps as it did, and its right border is drawn on - screen and on paper wherever its margin is wide enough. + border on screen. A bordered panel reaching its cell's text edge now ends its whole right border + plus half a point short of that edge, the points taken from its right margin and no more than + that margin holds: its text stands where it did and wraps as it did, and its right border is + drawn on screen and on paper wherever its margin is wide enough. - **A ruled table's rows are as tall in Word as on the page, and a table that states no rule is ruled as the page rules it.** Word makes room in a table for its horizontal rules — a rule between two rows half in each, the rule above the table and the one below it whole in their diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index ae2057653..3ec3fa26c 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -354,8 +354,8 @@ How it lands: no style fills is written white, as the page draws it on the card. Word draws a table's right border outside its right edge and, on screen, cuts off what passes its cell's edge and draws the cell's gridline there; a bordered panel reaching its cell's text edge ends its - right border and half a point short of it, the points taken from its right margin and no more - than it holds, so its text keeps its place and width. + whole right border plus half a point short of that edge, the points taken from its right + margin and no more than it holds, so its text keeps its place and width. - **Keeping together.** A `keepTogether()` panel the layout placed on one page is a row Word may not split. Anchors and keeps on the blocks inside a panel carry as they do anywhere else. diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelEdgeCasesTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelEdgeCasesTest.java index c55e42882..d97e9a06a 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelEdgeCasesTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelEdgeCasesTest.java @@ -164,8 +164,9 @@ void aNestedCardWithNoMarginStartsHalfItsBorderLeftOfTheCell() throws Exception @Test void aNestedCardWithTooThinAMarginGivesUpNoMoreThanItHolds() throws Exception { - // 1pt of padding against a 1.5pt border: the margin can give back 0.25pt, not the 2.25 - // the border would need. The text keeps its width; the border stays where it was. + // 1pt of padding against a 1.5pt border: the margin can give back 0.25pt, not the 3.5 + // the border and its clearance would need. The text keeps its width; the border moves in + // only by that quarter point. try (XWPFDocument document = export(page -> page.addSection("Outer", outer -> outer .fillColor(SURFACE) .addSection("Inner", inner -> inner @@ -192,9 +193,10 @@ void aNestedCardWithTooThinAMarginGivesUpNoMoreThanItHolds() throws Exception { @Test void aNestedCardAsWideAsItsCellEndsItsRightBorderInsideItAndKeepsItsTextsWidth() throws Exception { - // Word starts a nested table no further left than its cell's text, centres its borders on - // its edges and, on screen, cuts off what passes the cell's right edge: a card as wide as - // its cell lost its right border. The points it gives up come off its right margin. + // Word starts a nested table no further left than its cell's text, draws its right border + // outside its right edge and, on screen, cuts off what passes the cell's edge and draws + // the cell's gridline there: a card as wide as its cell lost its right border. The points + // it gives up come off its right margin. try (XWPFDocument document = export(page -> page.addSection("Outer", outer -> outer .fillColor(SURFACE) .addSection("Inner", inner -> inner @@ -213,11 +215,12 @@ void aNestedCardAsWideAsItsCellEndsItsRightBorderInsideItAndKeepsItsTextsWidth() var margins = nested.getRow(0).getCell(0).getCTTc().getTcPr().getTcMar(); long text = width - DocxTwips.of(margins.getLeft().getW()) - DocxTwips.of(margins.getRight().getW()); - // Word draws the right border, 1.5pt or 30 twips, outside the table's edge. The text - // is as wide as the card written as wide as its cell, every border inside the cell's - // margins: its room less its 10pt padding each side, and the four half borders the - // margins give back. - assertThat(start + width + 30).as("its right border inside the cell").isLessThanOrEqualTo(room); + // Word draws the right border, 1.5pt or 30 twips, outside the table's edge, and the + // cell's gridline half a point further. The text is as wide as the card written as + // wide as its cell, every border inside the cell's margins: its room less its 10pt + // padding each side, and the four half borders the margins give back. + assertThat(start + width + 30 + 10).as("its right border inside the cell, clear of its gridline") + .isLessThanOrEqualTo(room); assertThat(text).as("its text as wide as on the page") .isCloseTo(room - 400 + 60, org.assertj.core.data.Offset.offset(3L)); }