From 29aea90981908763cd0402bb47a662366b33000c Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Mon, 19 Oct 2026 19:27:45 +0100 Subject: [PATCH] fix(docx): keep a panel's padding round its outline The page sets a shape container's padding round its outline and its layers in the outline itself. Written as a panel in a table cell, the padding was the cell's margins, inside the outline: measured in Word and LibreOffice, a 10pt padding stood the fill 10pt left of the page's and a top-left label 9pt low. The padding is now written as a margin is, the table's indent and the space above and below it, the cell's margins as for no padding, and the panel's width counts it beside the outline. Both editors set a cell's content at its middle or foot without the paragraph closing the cell after a table. Where the shape's table ends its cell in a row held at the page's height, a centred cell takes the space below off the space above; what it cannot, and the space below in a cell set at its foot, is named on the shape. --- CHANGELOG.md | 13 + .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 12 +- .../semantic/docx/DocxSemanticBackend.java | 90 ++++++- .../semantic/docx/DocxComposedCellTest.java | 20 +- .../docx/DocxNodeFieldLedgerTest.java | 7 +- .../docx/DocxPanelLayerAlignTest.java | 7 +- .../semantic/docx/DocxPanelPaddingTest.java | 232 ++++++++++++++++++ .../semantic/docx/DocxPanelTailTest.java | 22 +- 9 files changed, 372 insertions(+), 33 deletions(-) create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelPaddingTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index ea69baac0..f8a2e6969 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export keeps a panel's padding round its outline.** The page sets a shape container's + padding round its outline, and its layers in the outline itself. Written as a panel in a table + cell, the padding was the cell's margins, inside the outline. Measured in Word and LibreOffice + on Windows, a 10pt padding stood the fill 10pt left of the page's and a label at its top-left + 9pt low. A 20pt left padding stood a label at its bottom-right 20pt left. Neither was named. + - The padding is now written round the panel's table, as a margin is: its indent and the space + above and below it. Fill and text stand where the page sets them, in both editors, where the + cell sets what it holds at its top or its middle. + - Both editors set a cell's content at its middle or its foot without the space below a table + that ends it. Where the shape's table ends its cell, in a row held at the page's height, a + centred cell takes that space off the space above, which sets the shape where the page centres + it. What it cannot take (more space below than above), and the space below in a cell set at + its foot, is named on the shape: it stands that much lower. - **A DOCX export sets a panel's layer where its alignment sets it.** A filled or outlined rectangle or rounded rectangle with text, composed in a table cell, is written as a panel, a table of one cell, and its cell was always written centred. Measured in Word and LibreOffice on diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 568a4705e..8d8937840 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -123,7 +123,7 @@ honour an option ignores it (documented contract). | Viewer preferences | ✅ `applyViewerPreferences` in `PdfFixedLayoutBackend` | ❌ (ignored with a one-time warning — PDF-viewer concept) | n/a (not written; reported `DROPPED`, `viewer preferences`) | | Debug guide lines / node labels | ✅ `PdfGuideLinesRenderer`, `PdfNodeLabelRenderer` | ❌ (ignored with a one-time warning — render through the PDF backend to see overlays) | n/a | | Keep a block on one page (`keepTogether()`, `keepWithNext()`) | ✅ resolved by `LayoutCompiler` before any backend runs | ✅ same — the slides are the laid-out pages | ✅ `DocxSemanticBackend.keepOnOnePage` — Word re-paginates, so a block the layout placed on one page is told to stay there: `w:keepLines` on each of its paragraphs and `w:keepNext` on every one but the last (on the last too for `keepWithNext`), a table inside it chained row by row. A block that ran over a page break in the layout is taller than a page and is left to flow, as the layout left it. A body table of one row kept with what follows, its cells holding paragraphs alone, is written with a row that may break (`letTheOneRowTablesKeptWithTheNextBreak`): LibreOffice reads a one-row table whose row may not break as one that may not split, and keeps such a table with nothing | -| Layer stack — layers drawn over one another (`addLayerStack`) | ✅ each layer's fragments at the place the layout gave it | ✅ same | ⚠️ `DocxSemanticBackend` — Word has no layers, so a stack's children are written one after the other, without their positions. The exception is a stack whose layers are side-by-side columns (`DocxLayerColumns`): every layer a plain container at the stack's top-left corner, the bands their padding leaves either the same or apart. That is written as one table row, a cell per band, the way a two-column CV lays its columns out as layers to draw the name first. Layers sharing a band follow one another in its cell. A spacer the layout placed level with content of another layer of the same band only keeps that content's place, and is not written. The space above a later layer's first block is the gap the page shows below the content before it. Each layer is held to its box across, past the stack's edge where the page sets it past it, by an indent below zero; in a table cell no further left than the indents round it give, the rest named. The one layer of a stack of one layer is held the same way, its offset across kept and its measure with it; in a table cell, composed in a table's cell with no place of its own, and for an offset down or up, which moves it without making room for it, it is written where the stack starts and named — not where a band or a column's layer sets it at the page's distance below what is above it, which holds the offset. A shape container written as a panel in a table cell sets what its cell holds at the top, middle or foot as its first written layer is set, holds a paragraph across to its box, and names a written layer after the first (written under the ones before it), an offset down or up, a box past the shape's side, and a layer set across by a box the layout does not give (`layer offset`). A layer the page starts above where those before it end — a caption over its photo, a label in a card's corner — is written under them, in a column's cell as in a band, and the report names it; a stack composed in a table cell, or exported with no layout, has no place of its own, and one writing more than one of its layers is named with them not measured. What the page draws inside another layer's fill comes after that fill. Drawings stack as the page paints them, by their z-index, each given its place (`wp:anchor/@relativeHeight`) from where the page paints its fragment, whatever order the layers are written in — among the drawings behind the text, and among those in front of it, as Word draws every one behind under every one in front. Measured in LibreOffice: `CharcoalGold`, `SidebarPortrait` and `SlateOrange` fit on one page, as on the page; `NavySidebar` runs one line onto a second page. A stack or shape container the page gives no room — its margins taking back its whole height, as `LumaStudioInvoice`'s sidebar over the page's top margin — is laid over the flow (`laidOverTheFlow`): its drawings where the page draws them, each paragraph in a text box in front of the text where the page sets it, nothing in the flow. Not in a table cell or a panel, and not when it holds anything but drawing and plain paragraphs — a link, an anchor, a picture, a list or a table keeps it in the flow as above. A canvas is written as its contents — what it writes one block after another, its drawings where it places them — and holds its height: the room under what it writes is the space below it, and one that only draws holds its whole box where nothing round it — a band before its layer's first block, drawings held whole, an overlay it is a layer of — measures that room already. The report names the places and the width it loses, as it does a column's fixed width narrower than its band, and what it writes running past its height, a drawing in what it writes that takes no room in Word, or a height not measured | +| Layer stack — layers drawn over one another (`addLayerStack`) | ✅ each layer's fragments at the place the layout gave it | ✅ same | ⚠️ `DocxSemanticBackend` — Word has no layers, so a stack's children are written one after the other, without their positions. The exception is a stack whose layers are side-by-side columns (`DocxLayerColumns`): every layer a plain container at the stack's top-left corner, the bands their padding leaves either the same or apart. That is written as one table row, a cell per band, the way a two-column CV lays its columns out as layers to draw the name first. Layers sharing a band follow one another in its cell. A spacer the layout placed level with content of another layer of the same band only keeps that content's place, and is not written. The space above a later layer's first block is the gap the page shows below the content before it. Each layer is held to its box across, past the stack's edge where the page sets it past it, by an indent below zero; in a table cell no further left than the indents round it give, the rest named. The one layer of a stack of one layer is held the same way, its offset across kept and its measure with it; in a table cell, composed in a table's cell with no place of its own, and for an offset down or up, which moves it without making room for it, it is written where the stack starts and named — not where a band or a column's layer sets it at the page's distance below what is above it, which holds the offset. A shape container written as a panel in a table cell keeps its padding round its outline, as the page does — the table's indent and the space above and below it; where its table ends its cell, Word sets the cell's content at its middle or foot without the space below, which a centred cell takes off the space above, the rest named — sets what its cell holds at the top, middle or foot as its first written layer is set, holds a paragraph across to its box, and names a written layer after the first (written under the ones before it), an offset down or up, a box past the shape's side, and a layer set across by a box the layout does not give (`layer offset`). A layer the page starts above where those before it end — a caption over its photo, a label in a card's corner — is written under them, in a column's cell as in a band, and the report names it; a stack composed in a table cell, or exported with no layout, has no place of its own, and one writing more than one of its layers is named with them not measured. What the page draws inside another layer's fill comes after that fill. Drawings stack as the page paints them, by their z-index, each given its place (`wp:anchor/@relativeHeight`) from where the page paints its fragment, whatever order the layers are written in — among the drawings behind the text, and among those in front of it, as Word draws every one behind under every one in front. Measured in LibreOffice: `CharcoalGold`, `SidebarPortrait` and `SlateOrange` fit on one page, as on the page; `NavySidebar` runs one line onto a second page. A stack or shape container the page gives no room — its margins taking back its whole height, as `LumaStudioInvoice`'s sidebar over the page's top margin — is laid over the flow (`laidOverTheFlow`): its drawings where the page draws them, each paragraph in a text box in front of the text where the page sets it, nothing in the flow. Not in a table cell or a panel, and not when it holds anything but drawing and plain paragraphs — a link, an anchor, a picture, a list or a table keeps it in the flow as above. A canvas is written as its contents — what it writes one block after another, its drawings where it places them — and holds its height: the room under what it writes is the space below it, and one that only draws holds its whole box where nothing round it — a band before its layer's first block, drawings held whole, an overlay it is a layer of — measures that room already. The report names the places and the width it loses, as it does a column's fixed width narrower than its band, and what it writes running past its height, a drawing in what it writes that takes no room in Word, or a height not measured | ## Output surface and lifecycle diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index 4c4deb593..2fb9b59ab 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -978,10 +978,16 @@ recolour what is under it in Word and it keeps the colour it was flattened to. 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, its row held at least its outline's height less the borders both editors draw - outside it where its padding does not hold its top border, with its layers inside, + outside it, with its layers inside, its corners squared and reported — a rota's shift chips keep their colour and their - size. Its cell sets what it writes at the top, the middle or the foot, where the - first written layer's alignment sets that layer. A paragraph whose own alignment does + size. Its padding stands round the outline, as the page sets it, written as a margin + is: the table's indent and the space above and below it. Word and LibreOffice set a + cell's content at its middle or its foot without the space below a table that ends + it: where the shape's table ends its cell, in a row held at the page's height, a + centred cell's space above gives that up, and the shape stands where the page centres + it; what it cannot give, and the space below in a cell set at its foot, is named on + the shape. Its cell sets what it writes at the top, the middle or the + foot, where the first written layer's alignment sets that layer. A paragraph whose own alignment does not lean the way its layer's does, or that an offset moves across, is held in to its box: the cell's room less the width its table laid its lines out at, set at the alignment's share and moved by its offset. What the cell cannot set where the page does 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 acfbb80a0..92c05b087 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 @@ -805,6 +805,11 @@ private int startPageOf(DocumentNode node) { // The node that is all a composed cell holds, where the cell is all its row holds; null // otherwise (see giveBackWhatTheLabelNeeds). private DocumentNode aloneInItsRow; + // The node a composed cell is built from, and where the cell sets it down a row held at the + // page's height: 0 at the top, or where the row is not held, 0.5 the middle, 1 the foot (see + // setDownItsCell). + private DocumentNode composedCellContent; + private double composedCellDown; // How wide content may be inside that cell, so a table nested in it gets a width // instead of being squeezed by Word to a character a line. private double currentCellWidth = Double.NaN; @@ -1135,6 +1140,8 @@ private byte[] write(List sections, Path outputFile) throws Exc composedCellRepeats = false; composedCellRules = List.of(); composedRulesWritten = 0; + composedCellContent = null; + composedCellDown = 0; anchoredInCells.clear(); panelCell = null; moves.clear(); @@ -6717,6 +6724,53 @@ private void writePanel(XWPFDocument document, DocumentNode node, ContainerPaint */ private static final double CLEAR_OF_THE_GRIDLINE_POINTS = 0.5; + /** + * The space round a shape that is all its composed cell holds, where the cell sets it down its + * height as the page does. + * + *

Word and LibreOffice set a cell's content at its middle or its foot by what comes before + * the paragraph closing the cell after a table: the space below the shape's table is not + * counted there. Measured with a 10pt padding round a 60pt shape in an 80pt row: centred, the + * shape stood 5pt low in both; set at the foot, 10pt low; at the top, where the page sets it. In + * a centred cell the space above gives up the space below, and the shape stands where the page + * centres it. What it cannot give, and the space below in a cell set at the foot, is named.

+ */ + private DocumentInsets setDownItsCell(DocumentNode node, DocumentInsets margin) { + if (composedCellDown == 0 || !(margin.bottom() > 0.5) || !endsItsComposedCell(node)) { + return margin; + } + boolean atTheFoot = composedCellDown == 1; + double above = atTheFoot ? margin.top() : margin.top() - margin.bottom(); + double low = atTheFoot ? margin.bottom() : Math.max(0, -above) / 2; + if (low > 0.5) { + report.add(DocxExportReport.Severity.APPROXIMATED, node.nodeKind(), layout.pathOf(node), + "its table cell sets what it holds at its " + (atTheFoot ? "foot" : "middle") + ", and Word " + + "does not count the space below its table there: it stands " + pointsOf(low) + + "pt lower than the page sets it"); + } + return new DocumentInsets(Math.max(0, above), margin.right(), margin.bottom(), margin.left()); + } + + /** + * Whether a node is the last block its composed cell writes, its table the last thing in the + * cell: the node the cell is built from, or the last child down a run of blocks that write + * their children one after another and paint nothing — what comes above it, a heading or + * nothing, is counted as Word counts it. + */ + private boolean endsItsComposedCell(DocumentNode node) { + DocumentNode last = composedCellContent; + while (last != null && last != node) { + boolean flow = (last instanceof SectionNode || last instanceof ContainerNode) && paintOf(last).isEmpty() + || last instanceof com.demcha.compose.document.node.AlignNode + || last instanceof com.demcha.compose.document.layout.LayoutAnchorNode || isOneLayer(last); + if (!flow || last.children().isEmpty()) { + return false; + } + last = last.children().get(last.children().size() - 1); + } + return last == node; + } + /** * Writes one table of a panel: the whole panel, or the part of it between page breaks. * @@ -6730,6 +6784,16 @@ private void writePanelPiece(XWPFDocument document, DocumentNode node, Container CellContent content, boolean first, boolean last, boolean onANewPage) throws Exception { DocumentInsets margin = node.margin(); DocumentInsets padding = node.padding(); + if (node instanceof ShapeContainerNode) { + // The page sets a shape's padding round its outline, as a margin, and its layers in the + // outline itself (ShapeContainerDefinition). Written as the cell's margins, a 10pt padding + // stood the fill 10pt left of the page's and a label at its top-left 9pt low, in Word and + // LibreOffice. + margin = new DocumentInsets(margin.top() + padding.top(), margin.right() + padding.right(), + margin.bottom() + padding.bottom(), margin.left() + padding.left()); + padding = DocumentInsets.zero(); + margin = setDownItsCell(node, margin); + } DocumentBorders borders = paint.borders() == null ? DocumentBorders.NONE : paint.borders(); double halfLeft = strokeWidth(borders.left()) / 2; double halfRight = strokeWidth(borders.right()) / 2; @@ -7099,9 +7163,10 @@ private void holdTheSpaceAboveATable(XWPFDocument document) { */ private double panelWidth(DocumentNode node) { boolean known = currentCell != null ? Double.isFinite(currentCellWidth) : contentWidth < Double.MAX_VALUE; - double available = known - ? availableWidth() - node.margin().left() - node.margin().right() - : Double.NaN; + // A shape's padding stands round its outline, as its margin does (writePanelPiece). + double sides = node.margin().left() + node.margin().right() + + (node instanceof ShapeContainerNode ? node.padding().horizontal() : 0); + double available = known ? availableWidth() - sides : Double.NaN; java.util.OptionalDouble placed = layout.placedWidth(node); if (placed.isEmpty() && node instanceof ShapeContainerNode shape) { // Composed in a table cell, it has no placement; its outline states its size, within @@ -7666,11 +7731,11 @@ private void holdInItsPanel(ShapeContainerNode shape, DocumentNode node) { } /** - * The width a shape written as a panel leaves what it writes: its cell's, its outline less the - * padding written as the cell's margins. + * The width a shape written as a panel leaves what it writes: its outline's, as the page sets + * its layers in it, its padding written round it (writePanelPiece). */ private static double roomInItsPanel(ShapeContainerNode shape) { - return shape.outline().width() - shape.padding().horizontal(); + return shape.outline().width(); } /** @@ -15310,6 +15375,17 @@ private void writeCellContent(XWPFTableCell cell, TableGrid.Placement placement, boolean outerRepeats = composedCellRepeats; List outerRules = composedCellRules; int outerRulesWritten = composedRulesWritten; + DocumentNode outerContent = composedCellContent; + double outerDown = composedCellDown; + composedCellContent = source.content(); + // A row the layout does not hold — a table's own composed in a cell — Word sets as tall as + // what it holds, and it sets that from the top. + composedCellDown = layout.rowHeight(node, placement.row()).isEmpty() ? 0 + : switch (resolveCellAnchor(node, placement)) { + case TOP_LEFT, TOP_RIGHT -> 0; + case CENTER_LEFT, CENTER, CENTER_RIGHT -> 0.5; + case BOTTOM_LEFT, BOTTOM_RIGHT, DEFAULT -> 1; + }; currentCellWidth = usableWidthOf(cell, placement); tablesCells.add(cell.getCTTc()); // A table nested in a composed cell has no rows of its own in the layout: its cells @@ -15336,6 +15412,8 @@ private void writeCellContent(XWPFTableCell cell, TableGrid.Placement placement, composedCellRepeats = outerRepeats; composedCellRules = outerRules; composedRulesWritten = outerRulesWritten; + composedCellContent = outerContent; + composedCellDown = outerDown; } nameTheBorderEndingACell(source.content(), TableGrid.columnCount(node) == 1); return; 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 8dd2d6d02..5e5da8d23 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 @@ -468,14 +468,16 @@ void aDeepFacesLabelIsSeatedByItsLineNotAPosition() throws Exception { } @Test - void aPaddedChipsLabelIsSeatedInsideItsPadding() throws Exception { - // Its 2pt padding is written 40 twips above and below, as before; the line is seated. + void aPaddedChipsLabelIsSeatedInItsOutline() throws Exception { + // The page sets the padding round the outline: written round the chip's table, the outline + // holds its height whole, with no margin inside it, and the line is seated. Measured, the + // label stood on the page's baseline in Word and LibreOffice. XWPFTableCell chip = beside(labelledChip("09:00-16:00", com.demcha.compose.font.FontName.CARLITO, 10.4, 17.5, DocumentInsets.of(2), null, null)); assertThat(lineOf(chip)).isEqualTo(237 / 20.0); - assertThat(heightOf(chip.getTableRow().getTable())).isEqualTo(350 - 80); - assertThat(verticalMarginsOf(chip)).containsExactly(40L, 40L); + assertThat(heightOf(chip.getTableRow().getTable())).isEqualTo(350); + assertThat(verticalMarginsOf(chip)).containsExactly(0L, 0L); } @Test @@ -493,14 +495,14 @@ void aLabelTallerThanTheRoomWhoseLettersLeaveNothingToCutIsWrittenWhole() throws @Test void aPaddedChipsLabelItsOutlineHoldsIsSeatedOnTheOutlinesMiddle() throws Exception { // The page lays a shape's layers out in its outline, its padding round it: Lato's 14.4pt - // line fits the 17.5pt outline and stands on its middle. Cut to the 12.4pt its borders and - // margins leave, a point at each side, its text is lowered a point. Read as starting at - // the top of the outline less its padding, it stood half a point lower. + // line fits the 17.5pt outline and stands on its middle. Its padding written round the + // chip's table, the line holds the 15.3pt its borders leave and is seated there. Measured, + // the label stood on the page's baseline in Word and LibreOffice, to a tenth of a point. XWPFTableCell chip = beside(labelledChip("09:00-16:00", com.demcha.compose.font.FontName.LATO, 12, 17.5, DocumentInsets.of(2), OUTLINE, null)); - assertThat(lineOf(chip)).isEqualTo(12.4); - assertThat(positionOf(chip)).isEqualTo(-2); + assertThat(lineOf(chip)).isEqualTo(15.3); + assertThat(positionOf(chip)).isZero(); } @Test diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java index f9b811911..f35da2c77 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxNodeFieldLedgerTest.java @@ -305,7 +305,12 @@ private record Entry(Fate fate, String note) { "stroke:REPORTED:its translucency, as a panel's border, flattened against the panel's fill or " + "the colour under it, and as a panel's, " + BORDER_WIDTH + "; drawn, its alpha and its width " + "are written; any other is written", - "padding:WRITTEN", "margin:WRITTEN", + "padding:REPORTED:written as a panel's in a table cell, round its outline as a margin is, and " + + "below it, where its table ends its cell and the cell sets what it holds at its middle or " + + "foot, as far as Word does not count it there; any other is written", + "margin:REPORTED:written as a panel's in a table cell, below it, where its table ends its cell " + + "and the cell sets what it holds at its middle or foot, as far as Word does not count it " + + "there; any other is written", "transform:REPORTED"); node(ShapeNode.class, "name:INERT", "width:WRITTEN", "height:REPORTED:" + RULE_WIDTH, "fillColor:REPORTED:" + RULE_TRANSLUCENCY, diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelLayerAlignTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelLayerAlignTest.java index d57f4c19b..39509b3db 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelLayerAlignTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelLayerAlignTest.java @@ -156,13 +156,14 @@ void aCentredParagraphMovedAcrossPastTheShapesEdgeIsNamed() throws Exception { } @Test - void aPaddedShapesLayerIsHeldInTheRoomItsPaddingLeaves() throws Exception { + void aPaddedShapesLayerIsHeldInItsOutline() throws Exception { + // The page sets the padding round the outline and the layers in the outline itself. DocumentNode panel = new ShapeContainerBuilder().rectangle(ROOM, 60).fillColor(LIGHT).padding(5) .position(text("BotRight"), 0, 0, LayerAlign.BOTTOM_RIGHT).build(); Exported exported = export(panel); - assertThat(exported.left("BotRight")).as("at the right of the cell's text, its margins the padding") - .isCloseTo(Math.round((ROOM - 10 - exported.widthOf("BotRight")) * 20), within(2L)); + assertThat(exported.left("BotRight")).as("at the outline's right") + .isCloseTo(Math.round((ROOM - exported.widthOf("BotRight")) * 20), within(2L)); } @Test diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelPaddingTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelPaddingTest.java new file mode 100644 index 000000000..36a99f3f0 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelPaddingTest.java @@ -0,0 +1,232 @@ +package com.demcha.compose.document.backend.semantic.docx; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +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.table.DocumentTableCell; +import com.demcha.compose.document.table.DocumentTableColumn; +import com.demcha.compose.document.table.DocumentTableStyle; +import com.demcha.compose.document.table.DocumentTableTextAnchor; +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 org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTblPr; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTcMar; + +import java.io.ByteArrayInputStream; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A shape written as a panel in a table cell keeps its padding round its outline. + * + *

The page sets a shape's padding round its outline and its layers in the outline itself. + * Measured in Word 16 and LibreOffice on Windows, with the padding written as the panel cell's + * margins: a 10pt padding stood the fill 10pt left of the page's and a label at its top-left 9pt + * low, and a 20pt left padding stood a label at its bottom-right 20pt left. Written round the + * panel's table — its indent and the space above it — fill and text stood where the page sets + * them, in both, where its cell sets it at the top or at the middle. Both editors set a cell's + * content at its middle or foot without the space below a table that ends it, which is named.

+ * + * @author Artem Demchyshyn + */ +class DocxPanelPaddingTest { + + private static final DocumentColor LIGHT = DocumentColor.rgb(220, 230, 245); + + @Test + void thePaddingStandsRoundTheOutlineNotInsideIt() throws Exception { + Exported exported = export(panel(new DocumentInsets(0, 0, 0, 20)), null); + + assertThat(exported.panelIndent()).as("the table 20pt in").isEqualTo(400L); + assertThat(exported.panelMargins()).as("its cell's text at the outline").containsOnly(0L); + assertThat(exported.panelNotes()).isEmpty(); + } + + @Test + void aCentredCellsPanelGivesUpTheSpaceBelowFromTheSpaceAbove() throws Exception { + Exported exported = export(panel(DocumentInsets.of(10)), null); + + assertThat(exported.spaceAbove()).as("10pt above, less the 10pt below Word does not count").isZero(); + assertThat(exported.spaceBelow()).as("still written below the table").isEqualTo(200L); + assertThat(exported.panelNotes()).isEmpty(); + } + + @Test + void aTopCellsPanelKeepsTheSpaceAboveItsMarginAndPaddingTogether() throws Exception { + DocumentNode panel = new ShapeContainerBuilder().rectangle(100, 60).fillColor(LIGHT) + .padding(DocumentInsets.of(6)).margin(DocumentInsets.top(4)) + .layer(new ParagraphBuilder().text("Label").build(), LayerAlign.TOP_LEFT).build(); + Exported exported = export(panel, DocumentTableTextAnchor.TOP_LEFT); + + assertThat(exported.spaceAbove()).isEqualTo(200L); + assertThat(exported.panelNotes()).isEmpty(); + } + + @Test + void aCentredCellsPanelWithMoreSpaceBelowIsNamed() throws Exception { + Exported exported = export(panel(new DocumentInsets(4, 0, 16, 0)), null); + + assertThat(exported.spaceAbove()).isZero(); + assertThat(exported.panelNotes()).singleElement().extracting(DocxExportReport.Note::detail) + .isEqualTo("its table cell sets what it holds at its middle, and Word does not count the space below " + + "its table there: it stands 6pt lower than the page sets it"); + } + + @Test + void aPanelEndingWhatItsCellHoldsIsSetDownToo() throws Exception { + // A section that writes a heading and then the panel ends with the panel's table. + Exported exported = export(new SectionBuilder().addParagraph("Head").add(panel(DocumentInsets.of(10))).build(), null); + Exported unpadded = export(new SectionBuilder().addParagraph("Head").add(panel(DocumentInsets.zero())).build(), null); + + assertThat(exported.spaceAbove()).as("given up to the space below: the heading's own, as with no padding") + .isEqualTo(unpadded.spaceAbove()); + assertThat(exported.panelNotes()).isEmpty(); + } + + @Test + void aPanelSomethingFollowsKeepsTheSpaceAbove() throws Exception { + // The space below the table is the next paragraph's, which the cell counts. + DocumentNode section = new SectionBuilder().add(panel(DocumentInsets.of(10))).addParagraph("Tail").build(); + Exported exported = export(section, null); + + assertThat(exported.spaceAbove()).isEqualTo(200L); + assertThat(exported.panelNotes()).isEmpty(); + } + + @Test + void aPanelInARowTheLayoutDoesNotHoldKeepsTheSpaceAbove() throws Exception { + // A table composed in a cell has no rows of its own in the layout: Word sets them as tall + // as what they hold. + DocumentNode inner = new TableBuilder().columns(DocumentTableColumn.fixed(120)) + .rowCells(DocumentTableCell.node(panel(DocumentInsets.of(10)))).build(); + Exported exported = export(inner, null); + XWPFTableCell innerCell = exported.outerCell().getTables().get(0).getRow(0).getCell(0); + + XWPFTable panel = innerCell.getTables().get(0); + assertThat(spaceAbove(innerCell, panel)).isEqualTo(200L); + assertThat(exported.panelNotes()).isEmpty(); + } + + @Test + void aShapeWiderThanItsCellLessItsPaddingIsHeldInTheCell() throws Exception { + // A 110pt column leaves 102pt of text; the 10pt padding stands left of the outline, which + // is held to the 92pt left beside it rather than run past the cell, which Word widens for. + DocumentNode panel = new ShapeContainerBuilder().rectangle(100, 60).fillColor(LIGHT) + .padding(new DocumentInsets(0, 0, 0, 10)) + .layer(new ParagraphBuilder().text("Label").build(), LayerAlign.TOP_LEFT).build(); + Exported exported = export(panel, null, 110); + + assertThat(exported.panelIndent()).isEqualTo(200L); + assertThat(DocxTwips.of(exported.panel().getCTTbl().getTblPr().getTblW().getW())) + .as("no wider than the cell's text less the padding").isLessThanOrEqualTo(Math.round((102 - 10) * 20.0)); + } + + @Test + void aFootCellsPanelIsNamed() throws Exception { + Exported exported = export(panel(DocumentInsets.of(10)), DocumentTableTextAnchor.BOTTOM_LEFT); + + assertThat(exported.panelNotes()).singleElement().extracting(DocxExportReport.Note::detail) + .isEqualTo("its table cell sets what it holds at its foot, and Word does not count the space below " + + "its table there: it stands 10pt lower than the page sets it"); + } + + @Test + void theDefaultAnchorIsTheFoot() throws Exception { + Exported exported = export(panel(DocumentInsets.of(10)), DocumentTableTextAnchor.DEFAULT); + + assertThat(exported.panelNotes()).singleElement().extracting(DocxExportReport.Note::detail).asString() + .startsWith("its table cell sets what it holds at its foot"); + } + + private static DocumentNode panel(DocumentInsets padding) { + return new ShapeContainerBuilder().rectangle(100, 60).fillColor(LIGHT).padding(padding) + .layer(new ParagraphBuilder().text("Label").build(), LayerAlign.TOP_LEFT).build(); + } + + /** The space the paragraph before a cell's table holds above it, in twips; 0 with none before it. */ + private static long spaceAbove(XWPFTableCell cell, XWPFTable table) { + List elements = cell.getBodyElements(); + int index = elements.indexOf(table); + if (index < 1 || !(elements.get(index - 1) instanceof XWPFParagraph before)) { + return 0; + } + return Math.max(0, before.getSpacingBefore()) + Math.max(0, before.getSpacingAfter()); + } + + private record Exported(XWPFDocument document, List notes) { + + XWPFTableCell outerCell() { + return document.getTables().get(0).getRow(0).getCell(0); + } + + XWPFTable panel() { + return outerCell().getTables().get(0); + } + + long panelIndent() { + CTTblPr properties = panel().getCTTbl().getTblPr(); + return properties.isSetTblInd() ? DocxTwips.of(properties.getTblInd().getW()) : 0L; + } + + List panelMargins() { + CTTcMar margins = panel().getRow(0).getCell(0).getCTTc().getTcPr().getTcMar(); + return List.of(DocxTwips.of(margins.getTop().getW()), DocxTwips.of(margins.getLeft().getW()), + DocxTwips.of(margins.getBottom().getW()), DocxTwips.of(margins.getRight().getW())); + } + + long spaceAbove() { + return DocxPanelPaddingTest.spaceAbove(outerCell(), panel()); + } + + /** The space the paragraph closing the cell after the panel's table holds below it, in twips. */ + long spaceBelow() { + List elements = outerCell().getBodyElements(); + int index = elements.indexOf(panel()); + assertThat(elements.get(index + 1)).as("the paragraph after the table").isInstanceOf(XWPFParagraph.class); + return Math.max(0, ((XWPFParagraph) elements.get(index + 1)).getSpacingAfter()); + } + + List panelNotes() { + return notes.stream().filter(note -> note.subject().equals("ShapeContainerNode")).toList(); + } + } + + private static Exported export(DocumentNode content, DocumentTableTextAnchor anchor) throws Exception { + return export(content, anchor, 140); + } + + private static Exported export(DocumentNode content, DocumentTableTextAnchor anchor, double column) + throws Exception { + AtomicReference report = new AtomicReference<>(); + byte[] docx; + try (DocumentSession session = GraphCompose.document().pageSize(400, 600).margin(DocumentInsets.of(72)).create()) { + session.pageFlow(page -> page + .addParagraph("Before") + .addTable(table -> { + table.columns(DocumentTableColumn.fixed(column), DocumentTableColumn.fixed(100)); + if (anchor != null) { + table.defaultCellStyle(DocumentTableStyle.builder().textAnchor(anchor).build()); + } + table.rowCells(DocumentTableCell.node(content), + DocumentTableCell.node(new ParagraphBuilder().text("Beside").build())); + }) + .addParagraph("After")); + docx = session.export(new DocxSemanticBackend(report::set)); + } + assertThat(report.get().notes()).noneMatch(note -> note.subject().equals("measured geometry")); + return new Exported(new XWPFDocument(new ByteArrayInputStream(docx)), report.get().notes()); + } +} diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelTailTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelTailTest.java index 71dd94598..a8342cdca 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelTailTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxPanelTailTest.java @@ -315,9 +315,10 @@ void aShapesBordersStayInsideItsOutline() throws Exception { } @Test - void aPaddedShapeHoldsItsOutlineLessBothBordersAndItsMargins() throws Exception { - // The page draws the borders and the padding inside the outline: the content held is - // what is left of it, both borders off, as the editors add both round the row's height. + void aPaddedShapeHoldsItsOutlineLessBothBorders() throws Exception { + // The page draws the borders inside the outline and sets the padding round it: the content + // held is the outline, both borders off, as the editors add both round the row's height, + // and the padding is written round the shape's table. var chip = new com.demcha.compose.document.dsl.ShapeContainerBuilder().name("Chip").roundedRect(90, 17.5, 4) .padding(DocumentInsets.of(2)).stroke(DocumentStroke.of(DocumentColor.rgb(26, 86, 148), 1.1)) .center(new com.demcha.compose.document.dsl.ParagraphBuilder().text("09:00").build()).build(); @@ -327,12 +328,12 @@ void aPaddedShapeHoldsItsOutlineLessBothBordersAndItsMargins() throws Exception XWPFTableCell cell = exported.document().getTables().get(0).getRow(0).getCell(1).getTables().get(0) .getRow(0).getCell(0); - assertThat(topMargin(cell)).as("its padding, less half its border").isEqualTo(29); - assertThat(bottomMargin(cell)).isEqualTo(29); + assertThat(topMargin(cell)).as("no padding inside the outline").isZero(); + assertThat(bottomMargin(cell)).isZero(); // The heavier border at the width Word is given, nine eighths of a point, counted as a row // counts it, 22.5 twips rounded down; the lighter one at its own. - assertThat(cell.getTableRow().getHeight()).as("17.5pt, less both margins and both borders") - .isEqualTo(Math.round((17.5 - 1.1) * 20) - 2 * 29 - 22); + assertThat(cell.getTableRow().getHeight()).as("17.5pt, less both borders") + .isEqualTo(Math.round((17.5 - 1.1) * 20) - 22); } @Test @@ -379,8 +380,9 @@ void aPanelsHangNothingTakesIsNamed() throws Exception { @Test void aShapesHangingLineIsHeldInItsOutline() throws Exception { - // Its layers are centred in the outline's height, which holds the line: its padding is - // left as it is, and nothing is named. + // Its layers are centred in the outline's height, which holds the line: nothing of it is + // taken from the space round it. Its padding stands below the outline, round the shape's + // table, not inside it. var chip = new com.demcha.compose.document.dsl.ShapeContainerBuilder().name("Chip").roundedRect(90, 30, 4) .padding(new DocumentInsets(0, 0, 6, 0)).fillColor(SURFACE) .center(new com.demcha.compose.document.dsl.ParagraphBuilder().textStyle(SMALL).inlineText("GitHub ", SMALL) @@ -392,7 +394,7 @@ void aShapesHangingLineIsHeldInItsOutline() throws Exception { XWPFTableCell cell = exported.document().getTables().get(0).getRow(0).getCell(1).getTables().get(0) .getRow(0).getCell(0); - assertThat(bottomMargin(cell)).isEqualTo(6 * 20); + assertThat(bottomMargin(cell)).isZero(); assertThat(exported.report().bySubject()).doesNotContainKey("space above"); }