From 29dfc78f77a49c636bd040f7bc8d2a2cbb547018 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Wed, 7 Oct 2026 02:22:43 +0100 Subject: [PATCH 1/2] fix(docx): hold a canvas's height in Word, and name only what Word cannot hold The page gives a canvas its height whatever it holds; the export wrote what it holds one block after another and dropped the room under it. The room under what it writes is now owed as the space below it, where it moves something, and a canvas that only draws holds its whole box where nothing round it holds it already. The report names what Word cannot hold: what it writes running past its height, a drawing in what it writes that takes no room, and a height not measured. --- CHANGELOG.md | 37 +- .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 14 +- .../semantic/docx/DocxLayoutMetrics.java | 19 +- .../semantic/docx/DocxSemanticBackend.java | 200 +++++-- .../semantic/docx/DocxCanvasRoomTest.java | 499 ++++++++++++++++++ .../docx/DocxFlowContainerReportTest.java | 52 +- .../docx/DocxNodeFieldLedgerTest.java | 7 +- 8 files changed, 747 insertions(+), 83 deletions(-) create mode 100644 render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCanvasRoomTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index caedcc4fd..77b454f18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,41 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX canvas holds its height.** The page gives a canvas its height whatever it holds. The + export wrote what it holds one block after another and dropped the room under it, so what + followed stood that much higher. The report named the loss only where something followed the + canvas in the flow. Elsewhere the room was lost without a note: + - a canvas that wrote something and was its row's tallest cell; + - a canvas that ended a layer stack's column; + - a canvas in a shape container's layer, followed there or ending it. + + What changes: + - **The room under what it writes** is written as the space below it. The next block takes + it: as its space above, or as the space below the paragraph before a table. A cell the + canvas ends holds it on its last paragraph. So a row is as tall as its tallest canvas, and a + column as long as the canvas ending it. At the end of the body, or before a page break, + nothing follows to move, and nothing is written. + - **A canvas that only draws** holds its whole room in a flow, a row's cell or a column, as a + stack of drawings does in the flow. Inside a shape container's layer it holds it too. + Where something round it measures that room already, it holds none of its own, so the room + is not counted twice: + - a band, whose space round what it writes is measured past the drawing; + - drawings held whole round it, a canvas's or a stack's; + - a stack, a shape container or a canvas it is a layer of. + + A timeline's marker, alone in its row's cell, holds its room in the cell. + - **The report names what Word cannot hold:** + - what a canvas writes running past its height, which Word makes room for and the page does + not; + - a drawing in what it writes, which takes no room in Word, so what stands below the + drawing stands higher by its room; + - a height nothing measures: in a table's composed cell, or with no layout. + - Across the DOCX fidelity corpus eight documents' bytes change, each in its timeline's marker + cells, which now hold their markers' room: `CharcoalGold`, `MidnightNavy`, `NavySidebar`, + `ProfessionalSidebar`, `SerifHeadline`, `TerracottaRail`, `VioletGrid` and `ModernReceipt`. + Word and LibreOffice render all eight as they did, every word and drawing where it stood. + - Ledger: `CanvasLayerNode.height` moves from a gap to `REPORTED`; no node-field gap remains. + - **A DOCX export keeps a colour's translucency where Word can hold it, and names where it cannot.** A translucent colour (`DocumentColor.rgba(...)`, `withOpacity(...)`) was written at full strength, without a note, on text, a table cell's shading, a panel's fill and borders, and a @@ -212,7 +247,7 @@ follow semantic versioning; release dates are ISO 8601. - that what it writes is written from its corner, one block after another, not where it places it — unless it stacks it that way; - that its height is not held, where it stands in a flow and something follows it there - — a timeline's marker, alone in its row's cell, moves nothing; + — no longer named, as the room is written: see "A DOCX canvas holds its height"; - its width, where its text wraps narrower than the column; - on a painted section in the flow the page lays out page by page — the body and the panels in it, not a row's or a table's cell, a layer or a layer stack's column, where the page diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index a49219db7..a2bfbdeb9 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -120,7 +120,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 | -| 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. What the page draws inside another layer's fill comes after that fill. Measured in LibreOffice: `CharcoalGold`, `SidebarPortrait` and `SlateOrange` fit on one page, as on the page, with `SidebarPortrait`'s subtitle under its name strip rather than in it; `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 the report names the places, the room and the width it loses, as it does a column's fixed width narrower than its band | +| 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. What the page draws inside another layer's fill comes after that fill. Measured in LibreOffice: `CharcoalGold`, `SidebarPortrait` and `SlateOrange` fit on one page, as on the page, with `SidebarPortrait`'s subtitle under its name strip rather than in it; `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 room where nothing round it — a band, drawings held whole, an overlay it is a layer of — holds it 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 1a682d046..a0b90e6c6 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -714,11 +714,15 @@ recolour what is under it in Word and it keeps the colour it was flattened to. and on its left are not written round the table; its report note says so. See [charts.md](charts.md). - **A canvas → its contents.** A canvas's drawings stand where it places them; what it writes — text, pictures, tables — is written one block after another inside its margin and - padding. The places it gives them, the room it holds and the width its text wraps at are - not carried, and the report names each one it loses — the room where something follows the - canvas in its flow, so a timeline's marker, alone in its row's cell, has no note; a canvas - that is its row's tallest cell does not yet have one either. A canvas's clip policy paints - nothing on the page either. + padding. The places it gives them and the width its text wraps at are not carried, and the + report names each one it loses. Its height is held: the room under what it writes is the + space below it, which the next block takes or the cell it ends holds. One that only draws + holds its whole room in a flow, a cell, a column or a shape container's layer — not in a + band or in drawings held whole round it, which hold that room already, nor as a layer of a + stack, a shape container or a canvas. The report names what Word cannot hold: what it writes running past its height, a + drawing in what it writes, which takes no room in Word, and a height not measured, in a + table's composed cell or with no layout. A canvas's clip policy paints nothing on the page + either. - **Columns drawn as layers → one table row.** A two-column page can lay its columns out as the layers of one stack, each inset to its band, so the name is drawn before the sidebar. Word has no layers. When every layer is a plain diff --git a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java index 077302ee7..c40f10902 100644 --- a/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java +++ b/render-docx/src/main/java/com/demcha/compose/document/backend/semantic/docx/DocxLayoutMetrics.java @@ -1331,11 +1331,16 @@ private boolean aRowsOwnFill(PlacedFragment fragment) { if (!(fragment.payload() instanceof com.demcha.compose.document.layout.payloads.ShapeFragmentPayload)) { return false; } + return nodesByPath().get(fragment.path()) instanceof com.demcha.compose.document.node.RowNode; + } + + /** The nodes this index knows, by path, built when first asked. */ + private Map nodesByPath() { if (nodesByPath == null) { nodesByPath = new HashMap<>(); paths.forEach((node, path) -> nodesByPath.putIfAbsent(path, node)); } - return nodesByPath.get(fragment.path()) instanceof com.demcha.compose.document.node.RowNode; + return nodesByPath; } /** @@ -1556,6 +1561,18 @@ boolean followedInItsParent(DocumentNode node) { /** The highest child index the layout placed under each parent path, built when first asked. */ private Map lastChildIndex; + /** + * The node the layout placed a node in. + * + * @param node a placed node + * @return its parent, or {@code null} when the node was not placed or its parent is not a + * node this index knows + */ + DocumentNode parentOf(DocumentNode node) { + PlacedNode box = placedFor(node); + return box == null || box.parentPath() == null ? null : nodesByPath().get(box.parentPath()); + } + /** The placed node for a semantic node, or null when this index knows neither. */ private PlacedNode placedFor(DocumentNode node) { String path = paths.get(node); 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 041c611a3..ab8dd98fb 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 @@ -234,6 +234,10 @@ public final class DocxSemanticBackend implements SemanticBackend { private int overTheFlowDepth; // How many of those overlays are layer stacks of one layer, which lay nothing over anything. private int oneLayerDepth; + // How many of the nodes the writer is inside are stacks or shape containers of drawings held + // whole (holdTheSpaceOf), whose room holds all they hold. A band holds the room round what it + // writes too (bandDepth). A canvas inside either holds no room of its own (writeCanvas). + private int roomHeldAround; // The overlays being written, innermost first (see drawsInFront). private final java.util.Deque openOverlays = new java.util.ArrayDeque<>(); // How far the text of the band written last hangs below the band, in points: space the @@ -727,6 +731,7 @@ private byte[] write(List sections, Path outputFile) throws Exc bandDepth = 0; overTheFlowDepth = 0; oneLayerDepth = 0; + roomHeldAround = 0; openOverlays.clear(); forgetTheHang(); raisedRows.clear(); @@ -2724,12 +2729,14 @@ && holdTheSpaceOf(node)) { clipContainer = badge; } overlayDepth++; + roomHeldAround++; try { for (DocumentNode child : node.children()) { writeNode(document, child); } } finally { overlayDepth--; + roomHeldAround--; clipContainer = outerClip; } return; @@ -4500,6 +4507,11 @@ private void writeContainerChildren(XWPFDocument document, DocumentNode node) th boolean known = currentCell != null ? Double.isFinite(currentCellWidth) : contentWidth < Double.MAX_VALUE; double room = availableWidth() - (node == leftMarginInCell ? 0 : node.margin().left()) - node.margin().right(); + if (node instanceof com.demcha.compose.document.node.CanvasLayerNode canvas) { + // A canvas paints nothing of its own, and its width is named as the width it places in. + writeCanvas(document, canvas, known ? room : Double.NaN); + return; + } boolean narrowed = (paint.isEmpty() || layout.placedWidth(node).isEmpty()) && node.flowWidth().isFixed() && known && holdsWrappedText(node) && node.flowWidth().points() < room - EDITOR_COLUMN_SLACK_POINTS - 0.5; @@ -4508,9 +4520,6 @@ && known && holdsWrappedText(node) alsoLost.add("its fixed width is not in the file, so its paragraphs and lists run the width of " + "the column it stands in"); } - if (node instanceof com.demcha.compose.document.node.CanvasLayerNode canvas) { - alsoLost.addAll(canvasLosses(canvas, known ? room : Double.NaN)); - } // A panel is a table as wide as its box, where the page paints its fill and borders past // it in the flow it pages (inThePagedFlow). if (!paint.isEmpty() && node.bleed().any() && inThePagedFlow()) { @@ -11747,49 +11756,172 @@ private static boolean holdsWrappedText(DocumentNode node) { } /** - * What a canvas written as its contents leaves out. Its drawings stand where it places them - * (drawOwnFragments); what it writes — text, pictures, tables — is written one block after - * another inside its margin and padding, so the places it gives them, the room it holds and - * the width its text wraps at are not in the file. + * What a canvas writes — text, pictures, tables — as Word stacks it, one block under another + * from its corner inside its margin and padding. * - * @param canvas the canvas - * @param room the width its margins leave it where it is written, or NaN when not known - * @return the phrases, empty when what it writes is one block at its corner, as tall as it, - * or when nothing follows it to move + * @param height how tall its blocks stand one under another on the page, their margins + * included + * @param measured whether the layout placed the canvas and every block it writes + * @param placedApart whether it places a block elsewhere than at its left edge, at the foot + * of the one before + * @param nothing whether it writes nothing: it holds nothing, or only what is drawn + * @param wrapsText whether a block it writes holds text that wraps at its width + * @param drawingInside whether a block it writes holds a drawing that takes no room in Word + * ({@link #holdsADrawingWithNoRoom}) */ - private List canvasLosses(com.demcha.compose.document.node.CanvasLayerNode canvas, double room) { - List lost = new ArrayList<>(3); - List written = new ArrayList<>(); - for (com.demcha.compose.document.node.CanvasChild child : canvas.placements()) { - if (!onlyDrawn(child.node())) { - written.add(child); - } - } - // Written one after another from its corner, they stand where it places them only where - // it stacks them that way, each at the foot of the one before. + private record CanvasWrites(double height, boolean measured, boolean placedApart, boolean nothing, + boolean wrapsText, boolean drawingInside) { + } + + /** What a canvas writes, as Word stacks it (see {@link CanvasWrites}). */ + private CanvasWrites canvasWrites(com.demcha.compose.document.node.CanvasLayerNode canvas) { double stacked = 0; + boolean measured = layout.placement(canvas) != null; boolean placedApart = false; - for (com.demcha.compose.document.node.CanvasChild child : written) { + boolean nothing = true; + boolean wrapsText = false; + boolean drawingInside = false; + for (com.demcha.compose.document.node.CanvasChild child : canvas.placements()) { + if (onlyDrawn(child.node())) { + continue; + } + nothing = false; + wrapsText |= holdsWrappedText(child.node()); + drawingInside |= holdsADrawingWithNoRoom(child.node()); + // Written one after another from its corner, they stand where it places them only + // where it stacks them that way, each at the foot of the one before. if (child.x() != 0 || Math.abs(child.y() - stacked) > 0.5) { placedApart = true; } com.demcha.compose.document.layout.PlacedNode placed = layout.placement(child.node()); - stacked += placed == null ? 0 - : placed.placementHeight() + child.node().margin().top() + child.node().margin().bottom(); + if (placed == null) { + // The layout places all a placed canvas holds; a block it did not is not measured + // rather than counted as none, which would owe room the page does not hold. + measured = false; + } else { + stacked += placed.placementHeight() + child.node().margin().top() + child.node().margin().bottom(); + } + } + return new CanvasWrites(stacked, measured, placedApart, nothing, wrapsText, drawingInside); + } + + /** + * Whether a block a canvas writes holds, in a flow of its own, a drawing that takes no room + * in Word. Inside a canvas a shape, a line or a stack of drawings is drawn where the page + * puts it and holds no room (dispatchNode, writeNodeContentOf), so what stands below it in + * that flow stands higher in Word, and the block shorter. A canvas in it holds its own room + * ({@link #writeCanvas}); an overlay's layers stand over one another, and take none on the + * page either. + */ + private boolean holdsADrawingWithNoRoom(DocumentNode block) { + if (!(block instanceof SectionNode || block instanceof ContainerNode + || block instanceof com.demcha.compose.document.node.AlignNode)) { + return false; } - if (placedApart) { + for (DocumentNode child : block.children()) { + if (!(child instanceof com.demcha.compose.document.node.CanvasLayerNode) && onlyDrawn(child) + || holdsADrawingWithNoRoom(child)) { + return true; + } + } + return false; + } + + /** + * Writes a canvas as its contents, holding the room it holds on the page. + * + *

The page gives a canvas its height, whatever it holds: what follows starts below it, + * and a cell or a layer it ends is as tall. What it writes is followed by the room it leaves + * under that, owed as the space below it: the next block takes it, as its space above or as + * the space below the paragraph before a table, and a cell it ends holds it on its last + * paragraph. At the end of the body's flow, or before a page break there, it moves nothing + * and is not owed.

+ * + *

One that writes nothing holds its whole room where it stands (holdTheSpaceOf), as a + * stack of drawings does in the flow, its drawings drawn where the page puts them. Not where + * what is round it measures that room already: in a band, whose space round what it writes + * is measured past its drawings; in a stack of drawings held whole, or a canvas that only + * draws, which holds its whole box; laid over the flow; or as a layer laid over another, whose + * overlay holds the room of its layers.

+ * + *

Where what it writes runs past its height, Word makes room for it that the page does + * not; where a drawing in what it writes takes no room in Word, what stands below it stands + * higher; and where the layout does not place it or a block it writes, its room is not + * measured. Each is named ({@link #canvasLosses}).

+ * + * @param room the width its margins leave it where it is written, or NaN when not known + */ + private void writeCanvas(XWPFDocument document, com.demcha.compose.document.node.CanvasLayerNode canvas, + double room) throws Exception { + CanvasWrites writes = canvasWrites(canvas); + boolean followed = followedInFlow.contains(canvas); + // Counted in overlayDepth itself: past one, it stands in an overlay's layer, whose foot a + // shape container measures its room from; a stack of one layer lays nothing over anything. + boolean moves = followed || currentCell != null || overlayDepth - oneLayerDepth > 1; + reportWrittenWithout(canvas, "written as its contents", canvasLosses(canvas, writes, followed, moves, room)); + if (writes.nothing() && moves && roomHeldAround == 0 && bandDepth == 0 && overTheFlowDepth == 0 + && !laidOverAnother(canvas) && holdTheSpaceOf(canvas)) { + followedInFlow.remove(canvas); + // The room held is its whole box: nothing in it owes more — not a container in it that + // writes nothing, whose edges are inside the box, nor a canvas in it holding its own. + double owed = pendingSpacingAfter; + for (DocumentNode child : canvas.children()) { + writeNode(document, child); + } + pendingSpacingAfter = owed; + return; + } + writeContainerBody(document, canvas); + double left = canvas.height() - writes.height(); + if (!writes.nothing() && writes.measured() && moves && left > 0.5) { + owePendingSpacingAfter(left); + } + } + + /** + * Whether a node is a layer the layout lays over another: a layer of a stack of several, of + * a shape container — over its outline — or of a canvas. + */ + private boolean laidOverAnother(DocumentNode node) { + DocumentNode parent = layout.parentOf(node); + return parent != null && isOverlay(parent) && !isOneLayer(parent); + } + + /** + * What a canvas written as its contents leaves out. Its drawings stand where it places them + * (drawOwnFragments); what it writes — text, pictures, tables — is written one block after + * another inside its margin and padding, so the places it gives them and the width its text + * wraps at are not in the file. Its room is written ({@link #writeCanvas}), except where what + * it writes runs past it, where a drawing in what it writes takes no room in Word, and where + * it is not measured — composed in a table cell, or with no layout. + * + * @param canvas the canvas + * @param writes what it writes + * @param followed whether something follows it in its flow + * @param moves whether its room moves anything (see {@link #writeCanvas}) + * @param room the width its margins leave it where it is written, or NaN when not known + * @return the phrases, empty when what it writes stands where it places it, no taller than it, + * and no narrower than the column it stands in + */ + private List canvasLosses(com.demcha.compose.document.node.CanvasLayerNode canvas, CanvasWrites writes, + boolean followed, boolean moves, double room) { + List lost = new ArrayList<>(4); + if (writes.placedApart()) { lost.add("what it writes is written from its corner, one block after another, not where it places it"); } - // In the flow — itself the only overlay round what it writes, a stack of one layer aside — - // it takes the room of what it writes, where the page gives it its height; that moves - // what follows it, where anything does (followedInFlow). Composed in a table cell, it has - // no placement, nor has what it writes, to measure by. - if (layout.placement(canvas) != null && overlayDepth - oneLayerDepth == 1 - && followedInFlow.contains(canvas) && Math.abs(canvas.height() - stacked) > 0.5) { - lost.add("its height is not held: what follows starts below what it writes"); + if (!writes.measured()) { + // Named only where it writes something, or where something follows it to move. + if (!writes.nothing() || followed) { + lost.add("its height is not measured, so it holds only the room of what it writes"); + } + } else if (moves && writes.height() > canvas.height() + 0.5) { + lost.add("what it writes runs past its height, which Word makes room for and the page does not"); + } + if (writes.drawingInside()) { + lost.add("a drawing in what it writes takes no room in Word, so what stands below the drawing stands " + + "higher by its room"); } - boolean writesText = written.stream().anyMatch(child -> holdsWrappedText(child.node())); - if (Double.isFinite(room) && writesText + if (Double.isFinite(room) && writes.wrapsText() && canvas.width() < room - canvas.padding().horizontal() - EDITOR_COLUMN_SLACK_POINTS - 0.5) { lost.add("its width is not in the file, so its paragraphs and lists run the width of the column " + "it stands in"); diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCanvasRoomTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCanvasRoomTest.java new file mode 100644 index 000000000..9fceedf9f --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCanvasRoomTest.java @@ -0,0 +1,499 @@ +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.CanvasLayerBuilder; +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.dsl.ParagraphBuilder; +import com.demcha.compose.document.dsl.SectionBuilder; +import com.demcha.compose.document.dsl.ShapeBuilder; +import com.demcha.compose.document.dsl.ShapeContainerBuilder; +import com.demcha.compose.document.layout.LayoutGraph; +import com.demcha.compose.document.layout.PlacedNode; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.LayerAlign; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentRowColumn; +import com.demcha.compose.document.table.DocumentTableColumn; +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.apache.poi.xwpf.usermodel.XWPFTableRow; +import org.junit.jupiter.api.Test; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTSpacing; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.STLineSpacingRule; + +import java.io.ByteArrayInputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * A canvas holds its height in Word as it does on the page. + * + *

The page gives a canvas its height whatever it holds, and Word writes what it holds one block + * after another: the room under what it writes was lost, so what followed it stood that much + * higher — in the flow, after a canvas that only draws, at the end of a row's cell it made the + * tallest, at the end of a layer stack's column, and in a shape container's layer. It is the + * space below what it writes now, and the report names only what Word cannot hold: what it writes + * running past its height, a drawing in what it writes that takes no room, and a height nothing + * measures. Where what is round a canvas measures its room already — a band, another canvas — it + * holds none of its own, so the room is not counted twice.

+ * + *

Each expected distance is the page's, read from the layout's placements; each Word distance + * is read from the file — the space above and below each paragraph, and its exact line.

+ */ +class DocxCanvasRoomTest { + + private static final DocumentColor INK = DocumentColor.rgb(26, 86, 148); + + @Test + void whatFollowsACanvasStartsBelowItsRoomNotBelowWhatItWrites() throws Exception { + Exported exported = export(page -> page + .addCanvas(360, 120, canvas -> canvas.position(paragraph("Caption"), 0, 0)) + .add(paragraph("Below"))); + + assertThat(gapInWord(exported.document(), "Caption", "Below")) + .as("the canvas's room under its caption") + .isCloseTo(gapOnThePage(exported.layout(), "Caption", "Below"), within(0.1)) + .isGreaterThan(80); + assertThat(exported.report().bySubject()).doesNotContainKey("CanvasLayerNode"); + } + + @Test + void aCanvasWithPaddingAndMarginHoldsItsWholeBox() throws Exception { + Exported exported = export(page -> page + .add(paragraph("Above")) + .addCanvas(360, 90, canvas -> canvas + .padding(new DocumentInsets(6, 0, 10, 0)) + .margin(new DocumentInsets(4, 0, 8, 0)) + .position(paragraph("Caption"), 0, 0)) + .add(paragraph("Below"))); + + assertThat(gapInWord(exported.document(), "Above", "Caption")).as("its top margin and padding") + .isCloseTo(gapOnThePage(exported.layout(), "Above", "Caption"), within(0.1)); + assertThat(gapInWord(exported.document(), "Caption", "Below")).as("its room, padding and margin below") + .isCloseTo(gapOnThePage(exported.layout(), "Caption", "Below"), within(0.1)); + } + + @Test + void aCanvasThatOnlyDrawsHoldsItsWholeRoom() throws Exception { + Exported exported = export(page -> page + .add(paragraph("Above")) + .addCanvas(100, 80, canvas -> canvas.position(new ShapeBuilder().size(40, 40).fillColor(INK).build(), + 20, 20)) + .add(paragraph("Below"))); + + assertThat(gapInWord(exported.document(), "Above", "Below")) + .isCloseTo(gapOnThePage(exported.layout(), "Above", "Below"), within(0.1)) + .isCloseTo(80, within(0.1)); + assertThat(exported.report().bySubject()).doesNotContainKey("CanvasLayerNode"); + } + + @Test + void aCanvasThatOnlyDrawsHoldsItsBoxOnceWhatItDrawsInHasEdgesToo() throws Exception { + // A marker's recipe is a section of its own; its edges are inside the canvas's box. + Exported exported = export(page -> page + .add(paragraph("Above")) + .addCanvas(100, 80, canvas -> canvas.position(new SectionBuilder() + .padding(new DocumentInsets(4, 0, 10, 0)) + .add(new ShapeBuilder().size(40, 40).fillColor(INK).build()).build(), 0, 0)) + .add(paragraph("Below"))); + + assertThat(gapInWord(exported.document(), "Above", "Below")).isCloseTo(80, within(0.1)); + } + + @Test + void aCanvasInAStackOfOneLayerHoldsItsRoomInTheFlow() throws Exception { + Exported writing = export(page -> page + .addLayerStack(stack -> stack.layer(new CanvasLayerBuilder(360, 80) + .position(paragraph("Caption"), 0, 0).build(), LayerAlign.TOP_LEFT)) + .add(paragraph("Below"))); + Exported drawing = export(page -> page + .add(paragraph("Above")) + .addLayerStack(stack -> stack.layer(new CanvasLayerBuilder(360, 80) + .position(new ShapeBuilder().size(40, 40).fillColor(INK).build(), 0, 0).build(), + LayerAlign.TOP_LEFT)) + .add(paragraph("Below"))); + + assertThat(gapInWord(writing.document(), "Caption", "Below")) + .isCloseTo(gapOnThePage(writing.layout(), "Caption", "Below"), within(0.1)) + .isGreaterThan(40); + assertThat(gapInWord(drawing.document(), "Above", "Below")).as("the canvas only draws") + .isCloseTo(gapOnThePage(drawing.layout(), "Above", "Below"), within(0.1)) + .isCloseTo(80, within(0.1)); + } + + @Test + void aCanvasItsRowsTallestCellHoldsTheRowAsTallAsItIs() throws Exception { + // The row is as tall as the canvas on the page; its cell writes the caption alone. + Exported exported = export(page -> page + .addRow("Row", row -> row.columns(DocumentRowColumn.fixed(120), DocumentRowColumn.weight(1)) + .addSection("Marker", cell -> cell + .add(new CanvasLayerBuilder(120, 90).position(paragraph("Caption"), 0, 0).build())) + .add(paragraph("Beside"))) + .add(paragraph("Below"))); + XWPFTableCell cell = exported.document().getTables().get(0).getRow(0).getCell(0); + + assertThat(heightInWord(cell)).as("the cell as tall as the row the page sets") + .isCloseTo(placed(exported.layout(), "Row").placementHeight(), within(0.1)); + assertThat(exported.report().bySubject()).doesNotContainKey("CanvasLayerNode"); + } + + @Test + void aCanvasEndingALayerStacksColumnHoldsTheColumnAsTallAsItIs() throws Exception { + Exported exported = export(600, page -> page.addLayerStack(stack -> stack + .layer(new SectionBuilder().name("Left").margin(new DocumentInsets(0, 300, 0, 0)) + .add(paragraph("Heading")) + .add(new CanvasLayerBuilder(200, 100).position(paragraph("Caption"), 0, 0).build()) + .build(), LayerAlign.TOP_LEFT) + .layer(new SectionBuilder().margin(new DocumentInsets(0, 0, 0, 280)) + .add(paragraph("Right")).build(), LayerAlign.TOP_LEFT)) + .add(paragraph("Below"))); + XWPFTableCell left = cellHolding(exported.document(), "Caption"); + + assertThat(heightInWord(left)).as("the column as long as the layer the page sets") + .isCloseTo(placed(exported.layout(), "Left").placementHeight(), within(0.1)); + } + + @Test + void aCanvasFollowedInsideAShapeContainersLayerHoldsItsRoomThere() throws Exception { + Exported exported = export(page -> page + .add(new ShapeContainerBuilder().name("Card").rectangle(300, 200) + .layer(new SectionBuilder() + .add(new CanvasLayerBuilder(200, 70).position(paragraph("Caption"), 0, 0).build()) + .add(paragraph("Below")) + .build(), LayerAlign.TOP_LEFT) + .build())); + + assertThat(gapInWord(exported.document(), "Caption", "Below")) + .isCloseTo(gapOnThePage(exported.layout(), "Caption", "Below"), within(0.1)) + .isGreaterThan(40); + } + + @Test + void aCanvasThatOnlyDrawsEndingAShapeContainersLayerHoldsItsRoomThere() throws Exception { + // The container measures the space under its layer from the layer's foot, which is the + // canvas's: the canvas holds its room above that space. + Exported exported = export(page -> page + .add(new ShapeContainerBuilder().name("Card").rectangle(300, 200) + .layer(new SectionBuilder() + .add(paragraph("Title")) + .add(new CanvasLayerBuilder(200, 100) + .position(new ShapeBuilder().size(40, 40).fillColor(INK).build(), 0, 0) + .build()) + .build(), LayerAlign.TOP_LEFT) + .build()) + .add(paragraph("Below"))); + + assertThat(gapInWord(exported.document(), "Title", "Below")) + .isCloseTo(gapOnThePage(exported.layout(), "Title", "Below"), within(0.1)) + .isGreaterThan(100); + } + + @Test + void aCanvasThatOnlyDrawsInABandHoldsNoRoomOfItsOwn() throws Exception { + // A band measures the space above what it writes on the page, past the canvas's drawing: + // held again, the title would stand the canvas's height low. + Exported exported = export(page -> page + .addLayerStack(stack -> stack + .layer(new ShapeBuilder().size(360, 80).fillColor(DocumentColor.rgb(238, 243, 249)).build(), + LayerAlign.TOP_LEFT) + .layer(new SectionBuilder() + .add(new CanvasLayerBuilder(60, 24) + .position(new ShapeBuilder().size(24, 24).fillColor(INK).build(), 0, 0) + .build()) + .add(paragraph("Title")) + .build(), LayerAlign.TOP_LEFT)) + .add(paragraph("Next"))); + PlacedNode title = placed(exported.layout(), "Title"); + double contentTop = exported.layout().canvas().height() - 20; + + assertThat(before(paragraphOf(exported.document(), "Title"))).as("the page's distance down to it, once") + .isCloseTo(contentTop - (title.placementY() + title.placementHeight()), within(0.1)) + .isCloseTo(24, within(0.1)); + } + + @Test + void aCanvasThatOnlyDrawsInsideAnotherCanvasHoldsNoRoomOfItsOwn() throws Exception { + // The outer canvas holds its whole room under what it writes; the seal it lays at its + // corner holds none of its own, or the outer one would stand the seal's height taller. + Exported exported = export(page -> page + .addCanvas(360, 200, canvas -> canvas + .position(new CanvasLayerBuilder(60, 60) + .position(new ShapeBuilder().size(60, 60).fillColor(INK).build(), 0, 0).build(), 0, 0) + .position(paragraph("Caption"), 0, 60)) + .add(paragraph("Below"))); + XWPFParagraph caption = paragraphOf(exported.document(), "Caption"); + + assertThat(before(caption) + lineOf(caption) + gapInWord(exported.document(), "Caption", "Below")) + .as("the outer canvas's 200pt, its caption written at its corner") + .isCloseTo(200, within(0.1)); + assertThat(detailOf(exported.report())).isEqualTo("written as its contents; what it writes is written from " + + "its corner, one block after another, not where it places it"); + } + + @Test + void aCanvasThatOnlyDrawsInsideWhatIsHeldWholeHoldsNoRoomOfItsOwn() throws Exception { + // Inside a canvas held whole, and inside a stack of drawings held whole, the room is held + // once, by the outer one. + Exported inACanvas = export(page -> page + .add(paragraph("Above")) + .addCanvas(100, 80, canvas -> canvas.position(new SectionBuilder() + .add(new CanvasLayerBuilder(40, 40) + .position(new ShapeBuilder().size(40, 40).fillColor(INK).build(), 0, 0).build()) + .add(new ShapeBuilder().size(40, 20).fillColor(INK).build()).build(), 0, 0)) + .add(paragraph("Below"))); + Exported inAStack = export(page -> page + .add(paragraph("Above")) + .addLayerStack(stack -> stack + .layer(new ShapeBuilder().size(100, 80).fillColor(INK).build(), LayerAlign.TOP_LEFT) + .layer(new SectionBuilder() + .add(new CanvasLayerBuilder(40, 40) + .position(new ShapeBuilder().size(40, 40).fillColor(INK).build(), 0, 0).build()) + .add(new ShapeBuilder().size(40, 20).fillColor(INK).build()).build(), + LayerAlign.TOP_LEFT)) + .add(paragraph("Below"))); + + assertThat(gapInWord(inACanvas.document(), "Above", "Below")).as("the outer canvas's 80pt") + .isCloseTo(80, within(0.1)); + assertThat(gapInWord(inAStack.document(), "Above", "Below")).as("the stack's 80pt") + .isCloseTo(gapOnThePage(inAStack.layout(), "Above", "Below"), within(0.1)) + .isCloseTo(80, within(0.1)); + } + + @Test + void aCanvasBeforeAPageBreakOwesNothingThere() throws Exception { + // The page ends there; space written below the paragraph above would stay on that page. + Exported drawing = export(page -> page + .add(paragraph("Above")) + .addCanvas(100, 80, canvas -> canvas.position(new ShapeBuilder().size(40, 40).fillColor(INK).build(), + 20, 20)) + .addPageBreak(pageBreak -> { }) + .add(paragraph("Next page"))); + Exported writing = export(page -> page + .add(paragraph("Above")) + .addCanvas(360, 80, canvas -> canvas.position(paragraph("Caption"), 0, 0)) + .addPageBreak(pageBreak -> { }) + .add(paragraph("Next page"))); + + assertThat(after(paragraphOf(drawing.document(), "Above"))).isZero(); + assertThat(before(paragraphOf(drawing.document(), "Next page"))).isZero(); + assertThat(after(paragraphOf(writing.document(), "Caption"))).isZero(); + assertThat(before(paragraphOf(writing.document(), "Next page"))).isZero(); + } + + @Test + void whatACanvasWritesPastItsHeightIsNamed() throws Exception { + // A line of text in a canvas 10pt tall: the page draws its foot over what follows, and + // Word makes room for all of it. + Exported exported = export(page -> page + .addCanvas(360, 10, canvas -> canvas.position(paragraph("Caption"), 0, 0)) + .add(paragraph("Below"))); + + assertThat(detailOf(exported.report())).isEqualTo("written as its contents; what it writes runs past its " + + "height, which Word makes room for and the page does not"); + assertThat(gapInWord(exported.document(), "Caption", "Below")).as("no room owed under it").isZero(); + } + + @Test + void aDrawingInWhatACanvasWritesIsNamedForTheRoomWordGivesItNone() throws Exception { + // Inside a canvas a shape is drawn where the page puts it, and holds no room: the label + // under it stands its height high. + Exported exported = export(page -> page + .addCanvas(360, 100, canvas -> canvas.position(new SectionBuilder() + .add(new ShapeBuilder().size(300, 40).fillColor(INK).build()) + .add(paragraph("Label")).build(), 0, 0)) + .add(paragraph("Below"))); + + assertThat(detailOf(exported.report())).isEqualTo("written as its contents; a drawing in what it writes " + + "takes no room in Word, so what stands below the drawing stands higher by its room"); + } + + @Test + void aCanvasComposedInATableCellNamesTheHeightNothingMeasures() throws Exception { + // In a composed cell nothing has a placement: what it writes cannot be measured. + Exported exported = export(page -> page + .addTable(table -> table.columns(DocumentTableColumn.fixed(200)) + .rowCells(com.demcha.compose.document.table.DocumentTableCell.node(new SectionBuilder() + .add(new CanvasLayerBuilder(180, 40).position(paragraph("Caption"), 0, 0).build()) + .add(paragraph("After")).build())))); + + // Narrower than its cell, its width is named too. + assertThat(detailOf(exported.report())).isEqualTo("written as its contents; its height is not measured, " + + "so it holds only the room of what it writes; its width is not in the file, so its paragraphs " + + "and lists run the width of the column it stands in"); + } + + @Test + void aCanvasWithNoLayoutNamesTheHeightNothingMeasures() throws Exception { + DocxExportReport writing = DocxExports.reportWithoutLayout(400, 600, 20, page -> page + .addCanvas(360, 120, canvas -> canvas.position(paragraph("Caption"), 0, 0)) + .add(paragraph("Below"))); + DocxExportReport drawingFollowed = DocxExports.reportWithoutLayout(400, 600, 20, page -> page + .addCanvas(100, 80, canvas -> canvas.position(new ShapeBuilder().size(40, 40).fillColor(INK).build(), + 20, 20)) + .add(paragraph("Below"))); + String unmeasured = "written as its contents; its height is not measured, so it holds only the room of " + + "what it writes"; + + assertThat(writing.bySubject().get("CanvasLayerNode")).extracting(DocxExportReport.Note::detail) + .containsExactly(unmeasured); + assertThat(drawingFollowed.bySubject().get("CanvasLayerNode")).extracting(DocxExportReport.Note::detail) + .as("only drawing, but something follows it to move").containsExactly(unmeasured); + } + + @Test + void aCanvasThatOnlyDrawsAndIsFollowedByNothingWithNoLayoutIsNotNamed() throws Exception { + DocxExportReport report = DocxExports.reportWithoutLayout(400, 600, 20, page -> page + .add(paragraph("Above")) + .addCanvas(100, 80, canvas -> canvas.position(new ShapeBuilder().size(40, 40).fillColor(INK).build(), + 20, 20))); + + assertThat(report.bySubject()).doesNotContainKey("CanvasLayerNode"); + } + + @Test + void aTimelinesMarkerAloneInItsCellHoldsItsRoomThere() throws Exception { + // Each marker is a canvas that only draws, alone in its row's cell: the cell holds its room + // above a hairline paragraph. + Exported exported = export(page -> page + .addTimeline(timeline -> timeline + .entry(com.demcha.compose.document.dsl.TimelineMarker.dot(8, INK), entry -> entry + .title("Senior Engineer").meta("2023 - Present"))) + .add(paragraph("Below"))); + XWPFTableCell marker = exported.document().getTables().get(0).getRow(0).getCell(0); + + assertThat(marker.getParagraphs()).hasSize(1); + assertThat(before(marker.getParagraphs().get(0))).as("the marker's 8pt").isCloseTo(8, within(0.1)); + assertThat(exported.report().bySubject()).doesNotContainKey("CanvasLayerNode"); + } + + private static DocumentNode paragraph(String text) { + return new ParagraphBuilder().name(text).text(text).margin(DocumentInsets.zero()).build(); + } + + private static String detailOf(DocxExportReport report) { + List notes = report.bySubject().get("CanvasLayerNode"); + assertThat(notes).as("the note on the canvas").isNotNull().hasSize(1); + return notes.get(0).detail(); + } + + /** + * The space Word puts between two paragraphs, one written right after the other: below the + * first and above the second. + */ + private static double gapInWord(XWPFDocument document, String above, String below) { + XWPFParagraph first = paragraphOf(document, above); + XWPFParagraph second = paragraphOf(document, below); + List elements = first.getBody().getBodyElements(); + assertThat(second.getBody()).as("%s and %s in one body", above, below).isSameAs(first.getBody()); + assertThat(elements.indexOf(second)).as("%s written right after %s", below, above) + .isEqualTo(elements.indexOf(first) + 1); + return after(first) + before(second); + } + + /** How far apart the page sets two placed blocks: the first's foot to the second's top. */ + private static double gapOnThePage(LayoutGraph layout, String above, String below) { + PlacedNode first = placed(layout, above); + PlacedNode second = placed(layout, below); + return first.placementY() - (second.placementY() + second.placementHeight()); + } + + /** How tall Word sets a cell's paragraphs, each an exact line with the space round it. */ + private static double heightInWord(XWPFTableCell cell) { + double height = 0; + for (XWPFParagraph paragraph : cell.getParagraphs()) { + height += before(paragraph) + lineOf(paragraph) + after(paragraph); + } + return height; + } + + private static PlacedNode placed(LayoutGraph layout, String name) { + return layout.nodes().stream().filter(node -> name.equals(node.semanticName())).findFirst() + .orElseThrow(() -> new AssertionError("no placed node named " + name)); + } + + private static double before(XWPFParagraph paragraph) { + CTSpacing spacing = spacingOf(paragraph); + return spacing == null || !spacing.isSetBefore() ? 0 : DocxTwips.of(spacing.getBefore()) / 20.0; + } + + private static double after(XWPFParagraph paragraph) { + CTSpacing spacing = spacingOf(paragraph); + return spacing == null || !spacing.isSetAfter() ? 0 : DocxTwips.of(spacing.getAfter()) / 20.0; + } + + /** A paragraph's exact line, in points. */ + private static double lineOf(XWPFParagraph paragraph) { + CTSpacing spacing = spacingOf(paragraph); + assertThat(spacing != null && spacing.getLineRule() == STLineSpacingRule.EXACT) + .as("%s written in an exact line", paragraph.getText()).isTrue(); + return DocxTwips.of(spacing.getLine()) / 20.0; + } + + private static CTSpacing spacingOf(XWPFParagraph paragraph) { + return paragraph.getCTP().isSetPPr() && paragraph.getCTP().getPPr().isSetSpacing() + ? paragraph.getCTP().getPPr().getSpacing() : null; + } + + private static XWPFParagraph paragraphOf(XWPFDocument document, String text) { + List all = new ArrayList<>(document.getParagraphs()); + for (XWPFTable table : document.getTables()) { + collect(table, all); + } + return all.stream().filter(paragraph -> paragraph.getText().equals(text)).findFirst() + .orElseThrow(() -> new AssertionError("no paragraph reading " + text)); + } + + private static void collect(XWPFTable table, List all) { + for (XWPFTableRow row : table.getRows()) { + for (XWPFTableCell cell : row.getTableCells()) { + all.addAll(cell.getParagraphs()); + for (XWPFTable nested : cell.getTables()) { + collect(nested, all); + } + } + } + } + + private static XWPFTableCell cellHolding(XWPFDocument document, String text) { + for (XWPFTable table : document.getTables()) { + for (XWPFTableRow row : table.getRows()) { + for (XWPFTableCell cell : row.getTableCells()) { + if (cell.getParagraphs().stream().anyMatch(paragraph -> paragraph.getText().equals(text))) { + return cell; + } + } + } + } + throw new AssertionError("no cell holding " + text); + } + + private record Exported(XWPFDocument document, LayoutGraph layout, DocxExportReport report) { + } + + private static Exported export(Consumer content) throws Exception { + return export(400, content); + } + + private static Exported export(double pageWidth, Consumer content) throws Exception { + AtomicReference report = new AtomicReference<>(); + try (DocumentSession session = GraphCompose.document() + .pageSize(pageWidth, 600) + .margin(DocumentInsets.of(20)) + .create()) { + session.pageFlow(content::accept); + LayoutGraph layout = session.layoutGraph(); + byte[] docx = session.export(new DocxSemanticBackend(report::set)); + return new Exported(new XWPFDocument(new ByteArrayInputStream(docx)), layout, report.get()); + } + } +} diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxFlowContainerReportTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxFlowContainerReportTest.java index 229542850..57648c1d4 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxFlowContainerReportTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxFlowContainerReportTest.java @@ -24,12 +24,13 @@ /** * What a container written as its contents leaves of its own layout is in its report note: a - * canvas's places, room and width, a panel's bleed, the fixed width of a layer stack's column, - * and the keep of a line drawn in the flow. + * canvas's places and width, a panel's bleed, the fixed width of a layer stack's column, and the + * keep of a line drawn in the flow. * - *

Each was left out in silence: a canvas's caption set at its middle came out at its top - * with everything under it risen to meet it, a band bled to the page's edges stopped at its - * box, and a column fixed narrower than its neighbours left it ran its text their way.

+ *

Each was left out in silence: a canvas's caption set at its middle came out at its top, a + * band bled to the page's edges stopped at its box, and a column fixed narrower than its + * neighbours left it ran its text their way. A canvas's room is held in Word + * ({@code DocxCanvasRoomTest}).

*/ class DocxFlowContainerReportTest { @@ -37,7 +38,8 @@ class DocxFlowContainerReportTest { private static final DocumentColor SURFACE = DocumentColor.rgb(238, 243, 249); @Test - void aCanvasNamesThePlacesRoomAndWidthItIsWrittenWithout() throws Exception { + void aCanvasNamesThePlacesAndWidthItIsWrittenWithout() throws Exception { + // Its room is held under what it writes (DocxCanvasRoomTest). DocxExportReport report = reportOf(page -> page .addCanvas(200, 120, canvas -> canvas.position(new ParagraphBuilder() .text("Set forty points in and sixty down, wrapping inside two hundred").build(), 40, 60)) @@ -45,23 +47,10 @@ void aCanvasNamesThePlacesRoomAndWidthItIsWrittenWithout() throws Exception { assertThat(detailOf(report, "CanvasLayerNode")).isEqualTo("written as its contents; " + "what it writes is written from its corner, one block after another, not where it places it; " - + "its height is not held: what follows starts below what it writes; " + "its width is not in the file, so its paragraphs and lists run the width of the column it " + "stands in"); } - @Test - void aCanvasThatOnlyDrawsHoldsNoRoom() throws Exception { - // Its drawings stand where it places them, and nothing is written to hold its height. - DocxExportReport report = reportOf(page -> page - .addCanvas(100, 80, canvas -> canvas.position(new ShapeBuilder().size(40, 40).fillColor(INK).build(), - 20, 20)) - .addParagraph("Below")); - - assertThat(detailOf(report, "CanvasLayerNode")) - .isEqualTo("written as its contents; its height is not held: what follows starts below what it writes"); - } - @Test void aCanvasWritingOneBlockAtItsCornerAsTallAsItLosesNothing() throws Exception { DocxExportReport report = reportOf(page -> page @@ -72,7 +61,7 @@ void aCanvasWritingOneBlockAtItsCornerAsTallAsItLosesNothing() throws Exception } @Test - void aCanvasNothingFollowsMovesNothing() throws Exception { + void aCanvasThatOnlyDrawsAtTheEndOfTheFlowIsNotNamed() throws Exception { DocxExportReport report = reportOf(page -> page .addParagraph("Above") .addCanvas(100, 80, canvas -> canvas.position(new ShapeBuilder().size(40, 40).fillColor(INK).build(), @@ -91,14 +80,12 @@ void aCanvasInAStackOfOneLayerStandsInTheFlowToo() throws Exception { .addParagraph("Below")); assertThat(detailOf(report, "CanvasLayerNode")).isEqualTo("written as its contents; " - + "what it writes is written from its corner, one block after another, not where it places it; " - + "its height is not held: what follows starts below what it writes"); + + "what it writes is written from its corner, one block after another, not where it places it"); } @Test - void aTimelinesMarkerHoldsItsRowAndMovesNothing() throws Exception { - // Each marker is a canvas alone in its row's cell: nothing follows it there, and the row - // is as tall as its tallest cell. + void aTimelinesMarkersAreNotNamed() throws Exception { + // Each marker is a canvas that only draws, alone in its row's cell, which holds its room. DocxExportReport report = reportOf(page -> page .addTimeline(timeline -> timeline .entry(com.demcha.compose.document.dsl.TimelineMarker.dot(8, INK), entry -> entry @@ -197,20 +184,7 @@ void aPanelBledAcrossPagesNamesItsBleed() throws Exception { } @Test - void aCanvasComposedInATableCellIsNotMeasuredForItsRoom() throws Exception { - // In a composed cell nothing has a placement: what it writes cannot be measured. - DocxExportReport report = reportOf(page -> page - .addTable(table -> table.columns(DocumentTableColumn.fixed(200)) - .rowCells(com.demcha.compose.document.table.DocumentTableCell.node(new SectionBuilder() - .add(new com.demcha.compose.document.dsl.CanvasLayerBuilder(180, 20) - .position(new SpacerBuilder().height(20).build(), 0, 0).build()) - .addParagraph("After").build())))); - - assertThat(report.bySubject()).doesNotContainKey("CanvasLayerNode"); - } - - @Test - void aCanvasBeforeAPageBreakMovesNothing() throws Exception { + void aCanvasThatOnlyDrawsBeforeAPageBreakIsNotNamed() throws Exception { DocxExportReport report = reportOf(page -> page .addCanvas(100, 80, canvas -> canvas.position(new ShapeBuilder().size(40, 40).fillColor(INK).build(), 20, 20)) 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 15f01eb01..bb31ee3fa 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 @@ -123,8 +123,11 @@ private record Entry(Fate fate, String note) { "transform:REPORTED", "anchor:WRITTEN"); node(CanvasLayerNode.class, "name:INERT", "width:REPORTED:the width its text wraps at", - "height:GAP:the room it holds as its row's tallest cell, or ending a band or a layer " - + "stack's column; in a flow something follows it in, it is reported", + "height:REPORTED:where what it writes runs past it, where a drawing in what it writes takes " + + "no room in Word, and where it is not measured, composed in a table cell or with no " + + "layout; inside a band or drawings held whole, or as a layer laid over another, the room is " + + "theirs; any other is written, as the room under what it " + + "writes, or as its whole room where it only draws", "placements:REPORTED:where what it writes stands; its drawings stand where it places them", "clipPolicy:INERT:the page clips no canvas", "padding:WRITTEN", "margin:WRITTEN"); node(ChartNode.class, "name:INERT", "spec:REPORTED", "style:REPORTED:in the chart's note", From 8b7158d6dff21a11d1cc3467ca8c5fc357738c92 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Wed, 7 Oct 2026 09:52:22 +0100 Subject: [PATCH 2/2] fix(docx): hold a drawing canvas's room between a band's blocks and at an entry's end, once A canvas that only draws held no room anywhere in a band, though the band measures only the space above its layer's first block; it now holds its box between a layer's blocks, and in a column, whose resume is measured to the drawing. It holds none of its own in a block another canvas counts as taking no room, nor owes its edges where it is not held. A timeline entry's wrapped body passes on what follows it, so a canvas ending the body holds its room above the next entry. The report names a canvas ending a cell with no layout, and a drawing inside a stack of one layer. --- CHANGELOG.md | 27 +- .../architecture/backend-capability-matrix.md | 2 +- docs/recipes/docx-export.md | 13 +- .../semantic/docx/DocxSemanticBackend.java | 101 ++++--- .../semantic/docx/DocxCanvasRoomTest.java | 262 ++++++++++++++---- .../docx/DocxNodeFieldLedgerTest.java | 11 +- 6 files changed, 302 insertions(+), 114 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77b454f18..4b0957f62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,13 @@ follow semantic versioning; release dates are ISO 8601. ### Public API - **A DOCX canvas holds its height.** The page gives a canvas its height whatever it holds. The - export wrote what it holds one block after another and dropped the room under it, so what + export writes what it holds one block after another, and dropped the room under it, so what followed stood that much higher. The report named the loss only where something followed the canvas in the flow. Elsewhere the room was lost without a note: - a canvas that wrote something and was its row's tallest cell; - - a canvas that ended a layer stack's column; - - a canvas in a shape container's layer, followed there or ending it. + - a canvas that ended a layer stack's column, or a timeline entry's body; + - a canvas in a shape container's layer, followed there or ending it; + - a canvas that only drew, between the blocks of a band's layer. What changes: - **The room under what it writes** is written as the space below it. The next block takes @@ -22,18 +23,20 @@ follow semantic versioning; release dates are ISO 8601. canvas ends holds it on its last paragraph. So a row is as tall as its tallest canvas, and a column as long as the canvas ending it. At the end of the body, or before a page break, nothing follows to move, and nothing is written. - - **A canvas that only draws** holds its whole room in a flow, a row's cell or a column, as a - stack of drawings does in the flow. Inside a shape container's layer it holds it too. - Where something round it measures that room already, it holds none of its own, so the room - is not counted twice: - - a band, whose space round what it writes is measured past the drawing; - - drawings held whole round it, a canvas's or a stack's; - - a stack, a shape container or a canvas it is a layer of. + - **A canvas that only draws** holds its whole box, its edges included, in a flow, a row's + cell, a column or a band's layer, as a stack of drawings does in the flow. Inside a shape + container's layer it holds it too. Where something round it measures that room already, it + holds none of its own, its edges included, so the room is not counted twice: + - before the first block a band's layer writes, which the band sets the page's distance + down to, past the drawing; + - drawings held whole round it: a stack's, a shape container's or a canvas's; + - a stack of several layers, a shape container or a canvas it is a layer of; + - a block that a canvas round it writes nothing of, and so counts as taking no room. A timeline's marker, alone in its row's cell, holds its room in the cell. - **The report names what Word cannot hold:** - - what a canvas writes running past its height, which Word makes room for and the page does - not; + - what a canvas writes, one block under another, running past its height, which Word makes + room for and the page does not; - a drawing in what it writes, which takes no room in Word, so what stands below the drawing stands higher by its room; - a height nothing measures: in a table's composed cell, or with no layout. diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index a2bfbdeb9..1f24eacfb 100644 --- a/docs/architecture/backend-capability-matrix.md +++ b/docs/architecture/backend-capability-matrix.md @@ -120,7 +120,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 | -| 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. What the page draws inside another layer's fill comes after that fill. Measured in LibreOffice: `CharcoalGold`, `SidebarPortrait` and `SlateOrange` fit on one page, as on the page, with `SidebarPortrait`'s subtitle under its name strip rather than in it; `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 room where nothing round it — a band, drawings held whole, an overlay it is a layer of — holds it 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. What the page draws inside another layer's fill comes after that fill. Measured in LibreOffice: `CharcoalGold`, `SidebarPortrait` and `SlateOrange` fit on one page, as on the page, with `SidebarPortrait`'s subtitle under its name strip rather than in it; `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 a0b90e6c6..f9c92b748 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -717,12 +717,13 @@ recolour what is under it in Word and it keeps the colour it was flattened to. padding. The places it gives them and the width its text wraps at are not carried, and the report names each one it loses. Its height is held: the room under what it writes is the space below it, which the next block takes or the cell it ends holds. One that only draws - holds its whole room in a flow, a cell, a column or a shape container's layer — not in a - band or in drawings held whole round it, which hold that room already, nor as a layer of a - stack, a shape container or a canvas. The report names what Word cannot hold: what it writes running past its height, a - drawing in what it writes, which takes no room in Word, and a height not measured, in a - table's composed cell or with no layout. A canvas's clip policy paints nothing on the page - either. + holds its whole box in a flow, a cell, a column, a band's layer or a shape container's + layer — not before the first block a band's layer writes, nor in drawings held whole round + it, which measure that room already, nor as a layer of a stack of several layers, a shape + container or a canvas. The report names what Word cannot hold: what it writes, one block + under another, running past its height; a drawing in what it writes, which takes no room in + Word; and a height not measured, in a table's composed cell or with no layout. A canvas's + clip policy paints nothing on the page either. - **Columns drawn as layers → one table row.** A two-column page can lay its columns out as the layers of one stack, each inset to its band, so the name is drawn before the sidebar. Word has no layers. When every layer is a plain 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 ab8dd98fb..0c4946824 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 @@ -235,8 +235,8 @@ public final class DocxSemanticBackend implements SemanticBackend { // How many of those overlays are layer stacks of one layer, which lay nothing over anything. private int oneLayerDepth; // How many of the nodes the writer is inside are stacks or shape containers of drawings held - // whole (holdTheSpaceOf), whose room holds all they hold. A band holds the room round what it - // writes too (bandDepth). A canvas inside either holds no room of its own (writeCanvas). + // whole (holdTheSpaceOf), whose room holds all they hold. A canvas inside one that only draws + // holds no room of its own (writeCanvas). private int roomHeldAround; // The overlays being written, innermost first (see drawsInFront). private final java.util.Deque openOverlays = new java.util.ArrayDeque<>(); @@ -11809,13 +11809,15 @@ private CanvasWrites canvasWrites(com.demcha.compose.document.node.CanvasLayerNo * Whether a block a canvas writes holds, in a flow of its own, a drawing that takes no room * in Word. Inside a canvas a shape, a line or a stack of drawings is drawn where the page * puts it and holds no room (dispatchNode, writeNodeContentOf), so what stands below it in - * that flow stands higher in Word, and the block shorter. A canvas in it holds its own room - * ({@link #writeCanvas}); an overlay's layers stand over one another, and take none on the - * page either. + * that flow stands higher in Word, and the block shorter. The flows are a section's, a + * container's, an alignment's and an anchor's, and a stack of one layer's, which lays + * nothing over anything. A canvas that only draws holds its own room ({@link #writeCanvas}); + * an overlay's layers stand over one another, and take none on the page either. */ private boolean holdsADrawingWithNoRoom(DocumentNode block) { if (!(block instanceof SectionNode || block instanceof ContainerNode - || block instanceof com.demcha.compose.document.node.AlignNode)) { + || block instanceof com.demcha.compose.document.node.AlignNode + || block instanceof com.demcha.compose.document.layout.LayoutAnchorNode || isOneLayer(block))) { return false; } for (DocumentNode child : block.children()) { @@ -11837,12 +11839,16 @@ private boolean holdsADrawingWithNoRoom(DocumentNode block) { * paragraph. At the end of the body's flow, or before a page break there, it moves nothing * and is not owed.

* - *

One that writes nothing holds its whole room where it stands (holdTheSpaceOf), as a - * stack of drawings does in the flow, its drawings drawn where the page puts them. Not where - * what is round it measures that room already: in a band, whose space round what it writes - * is measured past its drawings; in a stack of drawings held whole, or a canvas that only - * draws, which holds its whole box; laid over the flow; or as a layer laid over another, whose - * overlay holds the room of its layers.

+ *

One that writes nothing is its drawings, drawn where the page puts them, and holds its + * whole box where it stands (holdTheSpaceOf), as a stack of drawings does in the flow. + * Nothing in it owes more: not a container in it that writes nothing, whose edges are inside + * the box, nor a canvas in it holding its own — what was owed before its drawings stands + * after them. Not where what is round it measures that room already: + * before the first block a band's layer writes, which the band sets the page's distance down + * to, past the drawing (resumeSpacing); in a stack of drawings or a canvas that only draws, + * held whole; or where the layout lays it over another layer, or a canvas round it counts what + * it stands in as taking no room ({@link #roomMeasuredRound}). There it owes nothing, its + * edges included.

* *

Where what it writes runs past its height, Word makes room for it that the page does * not; where a drawing in what it writes takes no room in Word, what stands below it stands @@ -11859,32 +11865,45 @@ private void writeCanvas(XWPFDocument document, com.demcha.compose.document.node // shape container measures its room from; a stack of one layer lays nothing over anything. boolean moves = followed || currentCell != null || overlayDepth - oneLayerDepth > 1; reportWrittenWithout(canvas, "written as its contents", canvasLosses(canvas, writes, followed, moves, room)); - if (writes.nothing() && moves && roomHeldAround == 0 && bandDepth == 0 && overTheFlowDepth == 0 - && !laidOverAnother(canvas) && holdTheSpaceOf(canvas)) { - followedInFlow.remove(canvas); - // The room held is its whole box: nothing in it owes more — not a container in it that - // writes nothing, whose edges are inside the box, nor a canvas in it holding its own. - double owed = pendingSpacingAfter; - for (DocumentNode child : canvas.children()) { - writeNode(document, child); + if (!writes.nothing()) { + writeContainerBody(document, canvas); + double left = canvas.height() - writes.height(); + if (writes.measured() && moves && left > 0.5) { + owePendingSpacingAfter(left); } - pendingSpacingAfter = owed; return; } - writeContainerBody(document, canvas); - double left = canvas.height() - writes.height(); - if (!writes.nothing() && writes.measured() && moves && left > 0.5) { - owePendingSpacingAfter(left); + followedInFlow.remove(canvas); + // A band sets the page's distance down to its first block past the drawing; a layer + // stack's column, to its first leaf, drawing and all (DocxLayerColumns). + boolean measuredByABand = bandDepth > 0 && !Double.isNaN(resumeSpacing); + boolean held = moves && !measuredByABand && roomHeldAround == 0 && !roomMeasuredRound(canvas) + && holdTheSpaceOf(canvas); + // Held or not, nothing it holds owes more: what was owed before its drawings stands. + double owed = pendingSpacingAfter; + for (DocumentNode child : canvas.children()) { + writeNode(document, child); } + pendingSpacingAfter = owed; } /** - * Whether a node is a layer the layout lays over another: a layer of a stack of several, of - * a shape container — over its outline — or of a canvas. + * Whether the room of a canvas that only draws is measured by what is round it: the layout + * lays it over another layer — of a stack of several, of a shape container, over its outline, + * or of a canvas — or it stands in a block the nearest canvas round it writes nothing of, + * which that canvas counts as taking no room ({@link #canvasWrites}). */ - private boolean laidOverAnother(DocumentNode node) { - DocumentNode parent = layout.parentOf(node); - return parent != null && isOverlay(parent) && !isOneLayer(parent); + private boolean roomMeasuredRound(DocumentNode canvas) { + DocumentNode parent = layout.parentOf(canvas); + if (parent != null && isOverlay(parent) && !isOneLayer(parent)) { + return true; + } + DocumentNode block = canvas; + while (parent != null && !(parent instanceof com.demcha.compose.document.node.CanvasLayerNode)) { + block = parent; + parent = layout.parentOf(parent); + } + return parent != null && onlyDrawn(block); } /** @@ -11892,16 +11911,17 @@ private boolean laidOverAnother(DocumentNode node) { * (drawOwnFragments); what it writes — text, pictures, tables — is written one block after * another inside its margin and padding, so the places it gives them and the width its text * wraps at are not in the file. Its room is written ({@link #writeCanvas}), except where what - * it writes runs past it, where a drawing in what it writes takes no room in Word, and where - * it is not measured — composed in a table cell, or with no layout. + * it writes, one block under another, runs past it, where a drawing in what it writes takes no + * room in Word, and where it is not measured — composed in a table cell, or with no layout. * * @param canvas the canvas * @param writes what it writes * @param followed whether something follows it in its flow * @param moves whether its room moves anything (see {@link #writeCanvas}) * @param room the width its margins leave it where it is written, or NaN when not known - * @return the phrases, empty when what it writes stands where it places it, no taller than it, - * and no narrower than the column it stands in + * @return the phrases, empty when it is measured, and what it writes stands where it places + * it, no taller than it, holding no drawing that takes no room, and no narrower than + * the column it stands in */ private List canvasLosses(com.demcha.compose.document.node.CanvasLayerNode canvas, CanvasWrites writes, boolean followed, boolean moves, double room) { @@ -11910,12 +11930,14 @@ private List canvasLosses(com.demcha.compose.document.node.CanvasLayerNo lost.add("what it writes is written from its corner, one block after another, not where it places it"); } if (!writes.measured()) { - // Named only where it writes something, or where something follows it to move. - if (!writes.nothing() || followed) { + // Named only where it writes something, or where something follows it to move, or + // where it ends a cell with no layout, whose row nothing holds to the page's height. + if (!writes.nothing() || followed || currentCell != null && layout.isEmpty()) { lost.add("its height is not measured, so it holds only the room of what it writes"); } } else if (moves && writes.height() > canvas.height() + 0.5) { - lost.add("what it writes runs past its height, which Word makes room for and the page does not"); + lost.add("what it writes, one block under another, runs past its height, which Word makes room for " + + "and the page does not"); } if (writes.drawingInside()) { lost.add("a drawing in what it writes takes no room in Word, so what stands below the drawing stands " @@ -12757,6 +12779,11 @@ private void writeInBand(XWPFDocument document, insetRight += sides[1]; } try { + // What follows the band in its flow follows what it holds: a canvas ending an entry's + // body holds its room above the next entry (writeCanvas). + if (followedInFlow.remove(band)) { + followedInFlow.add(band.child()); + } writeNode(document, band.child()); } finally { insetLeft = outerLeft; diff --git a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCanvasRoomTest.java b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCanvasRoomTest.java index 9fceedf9f..f6d9a31d0 100644 --- a/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCanvasRoomTest.java +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxCanvasRoomTest.java @@ -31,6 +31,7 @@ import java.util.List; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; +import java.util.function.Function; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.within; @@ -44,11 +45,14 @@ * tallest, at the end of a layer stack's column, and in a shape container's layer. It is the * space below what it writes now, and the report names only what Word cannot hold: what it writes * running past its height, a drawing in what it writes that takes no room, and a height nothing - * measures. Where what is round a canvas measures its room already — a band, another canvas — it - * holds none of its own, so the room is not counted twice.

+ * measures. A canvas that only draws holds none of its own where what is round it measures that + * room already — before the first block a band's layer writes, in drawings held whole, as a + * layer laid over another, or in a block a canvas round it counts as taking no room — so the + * room is not counted twice.

* - *

Each expected distance is the page's, read from the layout's placements; each Word distance - * is read from the file — the space above and below each paragraph, and its exact line.

+ *

Each expected distance is the page's, read from the layout's placements, or the difference + * two layouts make; each Word distance is read from the file — the space above and below each + * paragraph, and its exact line.

*/ class DocxCanvasRoomTest { @@ -107,28 +111,30 @@ void aCanvasThatOnlyDrawsHoldsItsBoxOnceWhatItDrawsInHasEdgesToo() throws Except .add(new ShapeBuilder().size(40, 40).fillColor(INK).build()).build(), 0, 0)) .add(paragraph("Below"))); - assertThat(gapInWord(exported.document(), "Above", "Below")).isCloseTo(80, within(0.1)); + assertThat(gapInWord(exported.document(), "Above", "Below")) + .isCloseTo(gapOnThePage(exported.layout(), "Above", "Below"), within(0.1)); } @Test void aCanvasInAStackOfOneLayerHoldsItsRoomInTheFlow() throws Exception { + // A stack of one layer lays nothing over anything: a canvas in its layer stands in a flow. Exported writing = export(page -> page .addLayerStack(stack -> stack.layer(new CanvasLayerBuilder(360, 80) .position(paragraph("Caption"), 0, 0).build(), LayerAlign.TOP_LEFT)) .add(paragraph("Below"))); Exported drawing = export(page -> page .add(paragraph("Above")) - .addLayerStack(stack -> stack.layer(new CanvasLayerBuilder(360, 80) - .position(new ShapeBuilder().size(40, 40).fillColor(INK).build(), 0, 0).build(), - LayerAlign.TOP_LEFT)) - .add(paragraph("Below"))); + .addLayerStack(stack -> stack.layer(new SectionBuilder() + .add(new CanvasLayerBuilder(360, 80) + .position(new ShapeBuilder().size(40, 40).fillColor(INK).build(), 0, 0).build()) + .add(paragraph("Caption")).build(), LayerAlign.TOP_LEFT))); assertThat(gapInWord(writing.document(), "Caption", "Below")) .isCloseTo(gapOnThePage(writing.layout(), "Caption", "Below"), within(0.1)) .isGreaterThan(40); - assertThat(gapInWord(drawing.document(), "Above", "Below")).as("the canvas only draws") - .isCloseTo(gapOnThePage(drawing.layout(), "Above", "Below"), within(0.1)) - .isCloseTo(80, within(0.1)); + assertThat(gapInWord(drawing.document(), "Above", "Caption")).as("the canvas only draws, the caption under it") + .isCloseTo(gapOnThePage(drawing.layout(), "Above", "Caption"), within(0.1)) + .isGreaterThan(40); } @Test @@ -203,41 +209,138 @@ void aCanvasThatOnlyDrawsInABandHoldsNoRoomOfItsOwn() throws Exception { // A band measures the space above what it writes on the page, past the canvas's drawing: // held again, the title would stand the canvas's height low. Exported exported = export(page -> page - .addLayerStack(stack -> stack + .addLayerStack(stack -> stack.name("Band") .layer(new ShapeBuilder().size(360, 80).fillColor(DocumentColor.rgb(238, 243, 249)).build(), LayerAlign.TOP_LEFT) .layer(new SectionBuilder() - .add(new CanvasLayerBuilder(60, 24) - .position(new ShapeBuilder().size(24, 24).fillColor(INK).build(), 0, 0) - .build()) + .add(drawingCanvas("Logo", 60, 24)) .add(paragraph("Title")) .build(), LayerAlign.TOP_LEFT)) .add(paragraph("Next"))); + PlacedNode band = placed(exported.layout(), "Band"); PlacedNode title = placed(exported.layout(), "Title"); - double contentTop = exported.layout().canvas().height() - 20; assertThat(before(paragraphOf(exported.document(), "Title"))).as("the page's distance down to it, once") - .isCloseTo(contentTop - (title.placementY() + title.placementHeight()), within(0.1)) - .isCloseTo(24, within(0.1)); + .isCloseTo(band.placementY() + band.placementHeight() - (title.placementY() + title.placementHeight()), + within(0.1)) + .isCloseTo(placed(exported.layout(), "Logo").placementHeight(), within(0.1)); } @Test - void aCanvasThatOnlyDrawsInsideAnotherCanvasHoldsNoRoomOfItsOwn() throws Exception { - // The outer canvas holds its whole room under what it writes; the seal it lays at its - // corner holds none of its own, or the outer one would stand the seal's height taller. + void aCanvasThatOnlyDrawsBetweenABandLayersBlocksHoldsItsRoom() throws Exception { + // The band measures where its layer's first block stands, and how far its last stands + // from its foot; between them, its layer is a flow. + Exported exported = export(page -> page + .addLayerStack(stack -> stack + .layer(new ShapeBuilder().size(360, 120).fillColor(DocumentColor.rgb(238, 243, 249)).build(), + LayerAlign.TOP_LEFT) + .layer(new SectionBuilder() + .add(paragraph("Title")) + .add(drawingCanvas("Logo", 60, 24)) + .add(paragraph("Subtitle")) + .build(), LayerAlign.TOP_LEFT)) + .add(paragraph("Next"))); + + assertThat(gapInWord(exported.document(), "Title", "Subtitle")) + .isCloseTo(gapOnThePage(exported.layout(), "Title", "Subtitle"), within(0.1)) + .isGreaterThan(20); + } + + @Test + void aCanvasWritingInABandLayerHoldsItsRoomBeforeWhatFollowsThere() throws Exception { + Exported exported = export(page -> page + .addLayerStack(stack -> stack + .layer(new ShapeBuilder().size(360, 140).fillColor(DocumentColor.rgb(238, 243, 249)).build(), + LayerAlign.TOP_LEFT) + .layer(new SectionBuilder() + .add(new CanvasLayerBuilder(360, 60).position(paragraph("Caption"), 0, 0).build()) + .add(paragraph("Subtitle")) + .build(), LayerAlign.TOP_LEFT)) + .add(paragraph("Next"))); + + assertThat(gapInWord(exported.document(), "Caption", "Subtitle")) + .isCloseTo(gapOnThePage(exported.layout(), "Caption", "Subtitle"), within(0.1)) + .isGreaterThan(40); + } + + @Test + void aCanvasThatOnlyDrawsOpeningALaterLayerOfAColumnHoldsItsRoom() throws Exception { + // A later layer of a column resumes the page's distance from the content above it down to + // its own first leaf, the canvas's drawing: unlike a band's, it leaves the canvas's room + // to the canvas. + Exported exported = export(600, page -> page.addLayerStack(stack -> stack + .layer(new SectionBuilder().margin(new DocumentInsets(0, 300, 0, 0)) + .add(paragraph("Heading")).build(), LayerAlign.TOP_LEFT) + .layer(new SectionBuilder().margin(new DocumentInsets(0, 300, 0, 0)) + .padding(new DocumentInsets(30, 0, 0, 0)) + .add(drawingCanvas("Logo", 60, 24)) + .add(paragraph("Caption")).build(), LayerAlign.TOP_LEFT) + .layer(new SectionBuilder().margin(new DocumentInsets(0, 0, 0, 280)) + .add(paragraph("Right")).build(), LayerAlign.TOP_LEFT)) + .add(paragraph("Below"))); + + assertThat(gapInWord(exported.document(), "Heading", "Caption")) + .isCloseTo(gapOnThePage(exported.layout(), "Heading", "Caption"), within(0.1)) + .isGreaterThan(40); + } + + @Test + void aCanvasEndingAPaintedPanelHoldsItsRoomInThePanel() throws Exception { Exported exported = export(page -> page - .addCanvas(360, 200, canvas -> canvas - .position(new CanvasLayerBuilder(60, 60) - .position(new ShapeBuilder().size(60, 60).fillColor(INK).build(), 0, 0).build(), 0, 0) - .position(paragraph("Caption"), 0, 60)) + .addSection("Panel", panel -> panel.fillColor(DocumentColor.rgb(238, 243, 249)) + .add(new CanvasLayerBuilder(360, 80).position(paragraph("Caption"), 0, 0).build())) .add(paragraph("Below"))); - XWPFParagraph caption = paragraphOf(exported.document(), "Caption"); - assertThat(before(caption) + lineOf(caption) + gapInWord(exported.document(), "Caption", "Below")) - .as("the outer canvas's 200pt, its caption written at its corner") - .isCloseTo(200, within(0.1)); - assertThat(detailOf(exported.report())).isEqualTo("written as its contents; what it writes is written from " - + "its corner, one block after another, not where it places it"); + assertThat(heightInWord(cellHolding(exported.document(), "Caption"))).as("the panel as tall as the page's") + .isCloseTo(placed(exported.layout(), "Panel").placementHeight(), within(0.1)); + } + + @Test + void aCanvasThatOnlyDrawsInsideAnotherCanvasHoldsNoRoomOfItsOwn() throws Exception { + // The outer canvas holds its whole room under what it writes. What it lays at its corner, + // a seal or a block holding one, takes no room among what it writes, and holds none of its + // own — its edges included — or the outer canvas would stand that much taller. + for (DocumentNode corner : List.of( + drawingCanvas("Seal", 60, 60), + new CanvasLayerBuilder(60, 60).name("Seal").padding(DocumentInsets.of(6)) + .position(new ShapeBuilder().size(48, 48).fillColor(INK).build(), 0, 0).build(), + new SectionBuilder().add(drawingCanvas("Seal", 60, 60)).build())) { + Exported exported = export(page -> page + .add(new CanvasLayerBuilder(360, 200).name("Outer") + .position(corner, 0, 0) + .position(paragraph("Caption"), 0, 80).build()) + .add(paragraph("Below"))); + XWPFParagraph caption = paragraphOf(exported.document(), "Caption"); + + assertThat(before(caption) + lineOf(caption) + gapInWord(exported.document(), "Caption", "Below")) + .as("the outer canvas's height, its caption written at its corner") + .isCloseTo(placed(exported.layout(), "Outer").placementHeight(), within(0.1)); + assertThat(detailOf(exported.report())).isEqualTo("written as its contents; what it writes is written " + + "from its corner, one block after another, not where it places it"); + } + } + + @Test + void aCanvasThatOnlyDrawsLaidOverAnotherLayerHoldsNoRoomOfItsOwn() throws Exception { + // A layer of a shape container stands over its other layers on the page: its room is the + // container's, and what the container writes stands where it does without the canvas. + Function> card = withTheCanvas -> page -> { + ShapeContainerBuilder container = new ShapeContainerBuilder().name("Card").rectangle(300, 120); + if (withTheCanvas) { + container.layer(drawingCanvas("Seal", 60, 60), LayerAlign.TOP_LEFT); + } + page.add(paragraph("Above")) + .add(container.layer(paragraph("One"), LayerAlign.TOP_LEFT) + .layer(paragraph("Two"), LayerAlign.BOTTOM_LEFT).build()) + .add(paragraph("Below")); + }; + Exported with = export(card.apply(true)); + Exported without = export(card.apply(false)); + + assertThat(gapInWord(with.document(), "Above", "One")) + .isCloseTo(gapInWord(without.document(), "Above", "One"), within(0.01)); + assertThat(gapInWord(with.document(), "Two", "Below")) + .isCloseTo(gapInWord(without.document(), "Two", "Below"), within(0.01)); } @Test @@ -247,8 +350,7 @@ void aCanvasThatOnlyDrawsInsideWhatIsHeldWholeHoldsNoRoomOfItsOwn() throws Excep Exported inACanvas = export(page -> page .add(paragraph("Above")) .addCanvas(100, 80, canvas -> canvas.position(new SectionBuilder() - .add(new CanvasLayerBuilder(40, 40) - .position(new ShapeBuilder().size(40, 40).fillColor(INK).build(), 0, 0).build()) + .add(drawingCanvas("Inner", 40, 40)) .add(new ShapeBuilder().size(40, 20).fillColor(INK).build()).build(), 0, 0)) .add(paragraph("Below"))); Exported inAStack = export(page -> page @@ -256,17 +358,39 @@ void aCanvasThatOnlyDrawsInsideWhatIsHeldWholeHoldsNoRoomOfItsOwn() throws Excep .addLayerStack(stack -> stack .layer(new ShapeBuilder().size(100, 80).fillColor(INK).build(), LayerAlign.TOP_LEFT) .layer(new SectionBuilder() - .add(new CanvasLayerBuilder(40, 40) - .position(new ShapeBuilder().size(40, 40).fillColor(INK).build(), 0, 0).build()) + .add(drawingCanvas("Inner", 40, 40)) .add(new ShapeBuilder().size(40, 20).fillColor(INK).build()).build(), LayerAlign.TOP_LEFT)) .add(paragraph("Below"))); - assertThat(gapInWord(inACanvas.document(), "Above", "Below")).as("the outer canvas's 80pt") - .isCloseTo(80, within(0.1)); - assertThat(gapInWord(inAStack.document(), "Above", "Below")).as("the stack's 80pt") - .isCloseTo(gapOnThePage(inAStack.layout(), "Above", "Below"), within(0.1)) - .isCloseTo(80, within(0.1)); + assertThat(gapInWord(inACanvas.document(), "Above", "Below")).as("the outer canvas's room") + .isCloseTo(gapOnThePage(inACanvas.layout(), "Above", "Below"), within(0.1)); + assertThat(gapInWord(inAStack.document(), "Above", "Below")).as("the stack's room") + .isCloseTo(gapOnThePage(inAStack.layout(), "Above", "Below"), within(0.1)); + } + + @Test + void aCanvasEndingATimelineEntrysBodyHoldsItsRoomAboveTheNextEntry() throws Exception { + // On the rail, an entry's body is laid out under its row, in the content column; a canvas + // ending it holds its room above the next entry. Two canvases 60pt apart move the next + // entry 60pt on the page, and the space under the caption as much in Word. + Exported tall = export(timelineEndingABodyWithACanvas(80)); + Exported short_ = export(timelineEndingABodyWithACanvas(20)); + double onThePage = top(short_.layout(), "Engineer") - top(tall.layout(), "Engineer"); + + assertThat(after(paragraphOf(tall.document(), "Caption")) - after(paragraphOf(short_.document(), "Caption"))) + .isCloseTo(onThePage, within(0.1)) + .isCloseTo(60, within(0.1)); + } + + private static Consumer timelineEndingABodyWithACanvas(double height) { + return page -> page.addTimeline(timeline -> timeline.markerOnRail() + .entry(com.demcha.compose.document.dsl.TimelineMarker.dot(8, INK), entry -> entry + .title("Senior Engineer") + .add(body -> body.add(new CanvasLayerBuilder(300, height) + .position(paragraph("Caption"), 0, 0).build()))) + .entry(com.demcha.compose.document.dsl.TimelineMarker.dot(8, INK), entry -> entry + .content(content -> content.add(paragraph("Engineer"))))); } @Test @@ -298,23 +422,29 @@ void whatACanvasWritesPastItsHeightIsNamed() throws Exception { .addCanvas(360, 10, canvas -> canvas.position(paragraph("Caption"), 0, 0)) .add(paragraph("Below"))); - assertThat(detailOf(exported.report())).isEqualTo("written as its contents; what it writes runs past its " - + "height, which Word makes room for and the page does not"); + assertThat(detailOf(exported.report())).isEqualTo("written as its contents; what it writes, one block " + + "under another, runs past its height, which Word makes room for and the page does not"); assertThat(gapInWord(exported.document(), "Caption", "Below")).as("no room owed under it").isZero(); } @Test void aDrawingInWhatACanvasWritesIsNamedForTheRoomWordGivesItNone() throws Exception { // Inside a canvas a shape is drawn where the page puts it, and holds no room: the label - // under it stands its height high. - Exported exported = export(page -> page - .addCanvas(360, 100, canvas -> canvas.position(new SectionBuilder() - .add(new ShapeBuilder().size(300, 40).fillColor(INK).build()) - .add(paragraph("Label")).build(), 0, 0)) - .add(paragraph("Below"))); - - assertThat(detailOf(exported.report())).isEqualTo("written as its contents; a drawing in what it writes " - + "takes no room in Word, so what stands below the drawing stands higher by its room"); + // under it stands its height high — in a section, and in a stack of one layer, which lays + // nothing over anything. + DocumentNode inASection = new SectionBuilder() + .add(new ShapeBuilder().size(300, 40).fillColor(INK).build()) + .add(paragraph("Label")).build(); + DocumentNode inAStackOfOneLayer = new com.demcha.compose.document.dsl.LayerStackBuilder() + .layer(inASection, LayerAlign.TOP_LEFT).build(); + for (DocumentNode block : List.of(inASection, inAStackOfOneLayer)) { + Exported exported = export(page -> page + .addCanvas(360, 100, canvas -> canvas.position(block, 0, 0)) + .add(paragraph("Below"))); + + assertThat(detailOf(exported.report())).isEqualTo("written as its contents; a drawing in what it writes " + + "takes no room in Word, so what stands below the drawing stands higher by its room"); + } } @Test @@ -350,6 +480,19 @@ void aCanvasWithNoLayoutNamesTheHeightNothingMeasures() throws Exception { .as("only drawing, but something follows it to move").containsExactly(unmeasured); } + @Test + void aCanvasThatOnlyDrawsEndingARowsCellWithNoLayoutNamesTheHeightNothingMeasures() throws Exception { + // With no layout, nothing holds the row to the page's height. + DocxExportReport report = DocxExports.reportWithoutLayout(400, 600, 20, page -> page + .addRow(row -> row.columns(DocumentRowColumn.fixed(120), DocumentRowColumn.weight(1)) + .addSection("Marker", cell -> cell.add(drawingCanvas("Seal", 40, 40))) + .add(paragraph("Beside")))); + + assertThat(report.bySubject().get("CanvasLayerNode")).extracting(DocxExportReport.Note::detail) + .containsExactly("written as its contents; its height is not measured, so it holds only the room " + + "of what it writes"); + } + @Test void aCanvasThatOnlyDrawsAndIsFollowedByNothingWithNoLayoutIsNotNamed() throws Exception { DocxExportReport report = DocxExports.reportWithoutLayout(400, 600, 20, page -> page @@ -372,7 +515,8 @@ void aTimelinesMarkerAloneInItsCellHoldsItsRoomThere() throws Exception { XWPFTableCell marker = exported.document().getTables().get(0).getRow(0).getCell(0); assertThat(marker.getParagraphs()).hasSize(1); - assertThat(before(marker.getParagraphs().get(0))).as("the marker's 8pt").isCloseTo(8, within(0.1)); + assertThat(before(marker.getParagraphs().get(0))).as("the marker's room") + .isCloseTo(placed(exported.layout(), "marker").placementHeight(), within(0.1)); assertThat(exported.report().bySubject()).doesNotContainKey("CanvasLayerNode"); } @@ -380,6 +524,18 @@ private static DocumentNode paragraph(String text) { return new ParagraphBuilder().name(text).text(text).margin(DocumentInsets.zero()).build(); } + /** A canvas that only draws: a square as large as it, at its corner. */ + private static DocumentNode drawingCanvas(String name, double width, double height) { + return new CanvasLayerBuilder(width, height).name(name) + .position(new ShapeBuilder().size(width, height).fillColor(INK).build(), 0, 0).build(); + } + + /** Where the page sets a placed block's top, measured up from the page's foot. */ + private static double top(LayoutGraph layout, String name) { + PlacedNode placed = placed(layout, name); + return placed.placementY() + placed.placementHeight(); + } + private static String detailOf(DocxExportReport report) { List notes = report.bySubject().get("CanvasLayerNode"); assertThat(notes).as("the note on the canvas").isNotNull().hasSize(1); 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 bb31ee3fa..85369311a 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 @@ -123,11 +123,12 @@ private record Entry(Fate fate, String note) { "transform:REPORTED", "anchor:WRITTEN"); node(CanvasLayerNode.class, "name:INERT", "width:REPORTED:the width its text wraps at", - "height:REPORTED:where what it writes runs past it, where a drawing in what it writes takes " - + "no room in Word, and where it is not measured, composed in a table cell or with no " - + "layout; inside a band or drawings held whole, or as a layer laid over another, the room is " - + "theirs; any other is written, as the room under what it " - + "writes, or as its whole room where it only draws", + "height:REPORTED:where what it writes, one block under another, runs past it, where a drawing " + + "in what it writes takes no room in Word, and where it is not measured, composed in a table " + + "cell or with no layout; where it only draws, before a band's layer's first block, inside " + + "drawings held whole, as a layer laid over another or in a block a canvas round it counts " + + "as taking no room, the room is theirs; any other is written, as the room under what it " + + "writes, or as its whole box where it only draws", "placements:REPORTED:where what it writes stands; its drawings stand where it places them", "clipPolicy:INERT:the page clips no canvas", "padding:WRITTEN", "margin:WRITTEN"); node(ChartNode.class, "name:INERT", "spec:REPORTED", "style:REPORTED:in the chart's note",