diff --git a/CHANGELOG.md b/CHANGELOG.md index 14c4568a4..9618a05ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,32 @@ follow semantic versioning; release dates are ISO 8601. ### Public API +- **A DOCX export's report names what a container written as its contents leaves of its own + layout.** 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, a column fixed + narrower than its band ran its text the band's width, and a line kept with the next block + could end a page without it — and the report listed no loss. The container's note now + names: + - on a canvas, whose drawings stand where it places them: + - 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; + - 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 + bleeds nothing: its bleed, which stops its fill and borders at its box; + - on a section or container written as a layer stack's column: a fixed width narrower than + its band; + - on a line drawn in that same flow and kept with the next block, a page break aside: that + the keep is not carried — its drawing is anchored in a paragraph near it, and a page can + end between the two. + + A canvas's `clipPolicy` is recorded as having nothing to carry: the page clips no canvas. + None changes what is written: the 62 documents of the DOCX fidelity corpus export to the + same bytes. In `DocxNodeFieldLedgerTest` 6 node fields move from a gap to `REPORTED` and one + to `INERT`; 15 node-field gaps remain, each named — among them a canvas's height as its + row's tallest cell. - **A DOCX table with padding on its sides keeps the layout's grid, row heights and unbroken rows.** The page draws a table's rows inside its padding, narrower than the table's placement by that much, and the export matched a table's rows to the layout by the diff --git a/docs/architecture/backend-capability-matrix.md b/docs/architecture/backend-capability-matrix.md index 8c1e4df52..7ce0d410e 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 | +| 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 | ## Output surface and lifecycle diff --git a/docs/recipes/docx-export.md b/docs/recipes/docx-export.md index c2e02a913..3fd532a75 100644 --- a/docs/recipes/docx-export.md +++ b/docs/recipes/docx-export.md @@ -75,7 +75,7 @@ creation date is real metadata. | Images | Embedded pictures at the node's declared size. In the flow, the margin and padding above and below are the space round the picture's paragraph; on the left they are not written, and the report names them, as it does a barcode's and a page reference's sides. A picture drawn beside its text is fitted to its box with its padding in it, which the report names too | | Links and anchors | A `linkTarget` becomes a `w:hyperlink` — a relationship for an address, `w:anchor` for one of the document's own anchors — and a run's own link wins over the paragraph's — in a list item as much as in a paragraph. An `anchor(...)` becomes a bookmark wrapping that paragraph's text, named as Word requires; on a section, container, table or image it wraps everything the block wrote, from the start of its first paragraph to the end of its last, so a link to a block lands on its first line. A `bookmark(...)` outline level becomes Word's own `HeadingN` style, which is what puts the paragraph in the Navigation Pane, the outline view and a generated table of contents. The style states the outline level and nothing else, so the paragraph keeps its own formatting. The role comes from what the document declared, never from how big the text is | | Rows | A one-row table spanning the content width — or only its columns, where fixed columns leave part of the row empty — so editors keep the side-by-side layout. The row's slots become the column grid when they are weights, an even split or fixed columns; fixed columns that add up to less than the row leave the rest of it empty, the last one at its own width and the table no wider than its columns, so its text wraps where the page wraps it; the gap and the row's padding ride in the neighbouring column and come back out as that cell's margin; a cell holds whatever its child is, written as it is anywhere else. The row's `verticalAlign` is every cell's `w:vAlign`, so a child shorter than the row sits at its middle or bottom as on the page — a table of contents' leader on its entry's baseline. The row is kept whole across a page break, as the layout keeps it. A row is held at least as tall as the page makes it inside a painted panel, and anywhere its tallest child is a drawing Word holds nothing of in its cell — a badge beside a heading. A section or other container in any cell — a row's, a table's, a panel's — that pulls its first line up with a negative top edge writes that one-line paragraph as much shorter, its text seated where the page sets it — no more than the room above its letters, as Word draws an exact line's text only inside the line | -| Sections / containers | Children written in order. A timeline with its markers on the rail (`markerOnRail()`) lays each entry's body out in the header row's content column, below the row; the body is written in the flow, indented to that column where the page puts it. A container with a fill, per-side borders or a uniform stroke is a one-cell table carrying them, its padding as the cell's margins, so a card keeps its panel — see "What a panel keeps and loses" below. A `keepTogether()` or `keepWithNext()` block the layout placed on one page stays on one page in Word too (`w:keepLines` + `w:keepNext`, and a row that may not split for a panel). A box with no paint is only its contents, so a `fixedWidth` narrower than the column is not written: its paragraphs and lists run the column's width, as a panel's in a table cell do, and the report names it. Under an alignment wrapper, or as a layer of a band, the box is held in to where the page placed it | +| Sections / containers | Children written in order. A timeline with its markers on the rail (`markerOnRail()`) lays each entry's body out in the header row's content column, below the row; the body is written in the flow, indented to that column where the page puts it. A container with a fill, per-side borders or a uniform stroke is a one-cell table carrying them, its padding as the cell's margins, so a card keeps its panel — see "What a panel keeps and loses" below. A `keepTogether()` or `keepWithNext()` block the layout placed on one page stays on one page in Word too (`w:keepLines` + `w:keepNext`, and a row that may not split for a panel). A box with no paint is only its contents, so a `fixedWidth` narrower than the column is not written: its paragraphs and lists run the column's width, as a panel's in a table cell do, and the report names it. Under an alignment wrapper, or as a layer of a band, the box is held in to where the page placed it. A painted section's bleed is not written: its fill and borders stop at its box, and where the page bleeds it — in the flow it lays out page by page, the body and the panels in it — the report names it. A line drawn as a shape holds no keep: its drawing is anchored in a paragraph near it, a page can end between it and the next block, and in that same flow the report names the keep it loses | | Spacers | An empty paragraph a tenth of a point tall, which Word keeps (a shorter spacer stands that tenth); the rest of the spacer's height is the space above the next block, or below this paragraph when a table follows. A spacer in the body the layout moves to a new page with the gap before it, because the gap did not fit at the foot of the page above, holds the space the layout leaves above it there — that gap, and the edges of any containers opening with it — in its line, which Word keeps at the top of a page where it drops the space above. A spacer's own margin and padding are not written, and the report names them — except below the lowest block of a band, or of a layer another resumes after in its column, where the space is measured from the page, the block's own margin and padding included | | Page breaks | Explicit Word page breaks | @@ -654,6 +654,13 @@ tint it was flattened to. That is recorded with the rest. (values formatted with the chart's own axis format) and logs **one capability warning per export**. The chart's margin and padding above, below 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. - **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 10cf04d86..194722be8 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 @@ -248,6 +248,13 @@ public final class DocxSemanticBackend implements SemanticBackend { // layer stack they sit in is written as columns (DocxLayerColumns). private final java.util.Set standIns = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); + // The lines drawn in the flow whose keep with the next block Word is not given (keepGoesUnheld). + private final java.util.Set keepsUnheld = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); + // How many boxes the page places rather than flows — a row's or a table's cell, a layer + // stack's column — the writing stands in (inThePagedFlow). + private int slotDepth; + // The blocks something is written after in the flow they stand in. + private final java.util.Set followedInFlow = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); // Where the blocks are placed whose space below a band or a column measures from the page, // their own margin and padding below them included (DocxLayerColumns.Band#closing). private final java.util.Set closingBlocks = @@ -693,6 +700,9 @@ private byte[] write(List sections, Path outputFile) throws Exc warnedNodeKinds.clear(); zonePartsReported.clear(); closingBlocks.clear(); + keepsUnheld.clear(); + followedInFlow.clear(); + slotDepth = 0; surfaceBehind = null; overlayDepth = 0; bandDepth = 0; @@ -781,8 +791,12 @@ private byte[] write(List sections, Path outputFile) throws Exc "drawn as a line where the layout puts it, " + ANCHORED_BESIDE_ITS_TEXT); } } - for (DocumentNode root : section.graph().roots()) { - writeNode(document, root); + List roots = section.graph().roots(); + for (int root = 0; root < roots.size(); root++) { + if (root + 1 < roots.size() && !(roots.get(root + 1) instanceof PageBreakNode)) { + followedInFlow.add(roots.get(root)); + } + writeNode(document, roots.get(root)); } raiseRows(); // The paragraph closing a section that ends with a table is also the one that @@ -2724,6 +2738,10 @@ private boolean drawOwnFragments(DocumentNode node) { message.append("; ").append(lost); } } + if (keepsUnheld.remove(node)) { + message.append("; its keep with the next block is not carried, so a page can end between " + + "the two"); + } if (!isBuiltIn(node)) { message.append("; a node kind the DOCX export does not know: only the shapes it " + "paints itself are written, not its text, its pictures").append(heldNodes(node)); @@ -3862,9 +3880,21 @@ private void writeContainerChildren(XWPFDocument document, DocumentNode node) th boolean narrowed = (paint.isEmpty() || layout.placedWidth(node).isEmpty()) && node.flowWidth().isFixed() && known && holdsWrappedText(node) && node.flowWidth().points() < room - EDITOR_COLUMN_SLACK_POINTS - 0.5; - reportWrittenWithout(node, paint.isEmpty() ? "written as its contents" : "written as a panel", - narrowed ? List.of("its fixed width is not in the file, so its paragraphs and lists run " - + "the width of the column it stands in") : List.of()); + List alsoLost = new ArrayList<>(); + if (narrowed) { + 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()) { + alsoLost.add("its bleed is not in the file, so its fill and borders stop at its box, not at " + + "the page's edge"); + } + reportWrittenWithout(node, paint.isEmpty() ? "written as its contents" : "written as a panel", alsoLost); if (paint.isEmpty()) { writeContainerBody(document, node); return; @@ -4249,15 +4279,52 @@ private static void paintCellSide(CTBorder edge, DocumentStroke stroke) { */ private void writeChildren(XWPFDocument document, List children, double spacing) throws Exception { + writeChildren(document, children, spacing, false); + } + + /** + * Writes a flow's children one after another. + * + * @param lastFollowed whether something is written after the flow, so its last child is + * followed too + */ + private void writeChildren(XWPFDocument document, List children, double spacing, + boolean lastFollowed) throws Exception { for (int index = 0; index < children.size(); index++) { DocumentNode child = children.get(index); if (index > 0 && spacing > 0 && !(child instanceof PageBreakNode)) { owePendingSpacingAfter(spacing); } + if (index + 1 < children.size() ? !(children.get(index + 1) instanceof PageBreakNode) : lastFollowed) { + followedInFlow.add(child); + } + if (index + 1 < children.size() && keepGoesUnheld(child, children.get(index + 1))) { + keepsUnheld.add(child); + } writeNode(document, child); } } + /** + * Whether a line drawn in the flow, kept with the next block, is written apart from it: a + * drawing writes no paragraph to keep with the next, and its drawing is anchored in a + * paragraph near it, so a page can end between the two whatever the next block is. + */ + private boolean keepGoesUnheld(DocumentNode node, DocumentNode next) { + return inThePagedFlow() && node instanceof com.demcha.compose.document.node.LineNode line + && line.keepWithNext() && ruleOf(node) == null && !(next instanceof PageBreakNode); + } + + /** + * Whether what is being written stands in the flow the page lays out page by page: the body + * and the panels and boxes in it, not a row's cell, a table's composed cell, a layer stack's + * column or anything laid over another. Only there does the page keep a block with the next + * and bleed a section's paint toward its edges; everywhere else it places a fixed box. + */ + private boolean inThePagedFlow() { + return overlayDepth == 0 && slotDepth == 0; + } + /** The space a container puts between its children, zero for any other node. */ private static double spacingOf(DocumentNode node) { if (node instanceof SectionNode section) { @@ -4287,7 +4354,7 @@ private void writeContainerBody(XWPFDocument document, DocumentNode node) throws placeAcross(align, align.child()); } try { - writeChildren(document, node.children(), spacingOf(node)); + writeChildren(document, node.children(), spacingOf(node), followedInFlow.remove(node)); } finally { insetLeft = outerLeft; insetRight = outerRight; @@ -10322,6 +10389,8 @@ private void writeLayerColumns(XWPFDocument document, List layers = columns.get(index).layers(); double previous = currentCellWidth; currentCellWidth = usableWidthOf(cell, index, 1); + // A column's layers are boxes the page places, not its flow. + slotDepth++; try { writeInCell(cell, () -> { for (int layer = 0; layer < layers.size(); layer++) { @@ -10339,12 +10408,22 @@ private void writeLayerColumns(XWPFDocument document, } // A column layer is written as what it holds, never dispatched itself: // no bookmark goes round it, though bookmarkedAnchorsIn counts one. + List columnLost = new ArrayList<>(2); String anchor = blockAnchorOf(node, true); - reportWrittenWithout(node, "written as a column of its layer stack", - anchor == null || anchor.isBlank() ? List.of() - : List.of("its anchor has no bookmark in the Word file: a link " - + "or a page reference to it names a bookmark the file " - + "does not hold")); + if (anchor != null && !anchor.isBlank()) { + columnLost.add("its anchor has no bookmark in the Word file: a link or a " + + "page reference to it names a bookmark the file does not hold"); + } + // Its column runs between its neighbours' bands, not to the width it is fixed at. + com.demcha.compose.document.layout.PlacedNode placedLayer = layout.placement(node); + if (node.flowWidth().isFixed() && holdsWrappedText(node) && placedLayer != null + && Double.isFinite(currentCellWidth) + && placedLayer.placementWidth() - node.padding().horizontal() + < availableWidth() - EDITOR_COLUMN_SLACK_POINTS - 0.5) { + columnLost.add("its fixed width is not in the file, so its paragraphs and " + + "lists run the width of its column"); + } + reportWrittenWithout(node, "written as a column of its layer stack", columnLost); writeChildren(document, node.children(), spacingOf(node)); if (layer > 0 && blocksWritten == blocksBefore && holdsMovedContent(node)) { // A layer that wrote nothing — its content all written in an @@ -10360,6 +10439,7 @@ private void writeLayerColumns(XWPFDocument document, } }); } finally { + slotDepth--; currentCellWidth = previous; resumeSpacing = Double.NaN; } @@ -10600,6 +10680,57 @@ private static boolean holdsWrappedText(DocumentNode node) { || node.children().stream().anyMatch(DocxSemanticBackend::holdsWrappedText); } + /** + * 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. + * + * @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 + */ + 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. + double stacked = 0; + boolean placedApart = false; + for (com.demcha.compose.document.node.CanvasChild child : written) { + 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 (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"); + } + boolean writesText = written.stream().anyMatch(child -> holdsWrappedText(child.node())); + if (Double.isFinite(room) && writesText + && 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"); + } + return lost; + } + /** Whether a node is a paragraph of text or holds one. */ private static boolean holdsText(DocumentNode node) { return node instanceof ParagraphNode || node.children().stream().anyMatch(DocxSemanticBackend::holdsText); @@ -11849,11 +11980,14 @@ private void writeCellNodes(XWPFTableCell cell, List children) thr writeInCell(cell, () -> { DrawingCell outer = drawingCell; drawingCell = alone; + // A row's cell and a table's composed cell are boxes the page places, not its flow. + slotDepth++; try { for (DocumentNode child : children) { writeNode(cell.getXWPFDocument(), child); } } finally { + slotDepth--; drawingCell = outer; } }); 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 new file mode 100644 index 000000000..229542850 --- /dev/null +++ b/render-docx/src/test/java/com/demcha/compose/document/backend/semantic/docx/DocxFlowContainerReportTest.java @@ -0,0 +1,282 @@ +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.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.SpacerBuilder; +import com.demcha.compose.document.node.LayerAlign; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentEdge; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.table.DocumentTableColumn; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 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. + * + *

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.

+ */ +class DocxFlowContainerReportTest { + + private static final DocumentColor INK = DocumentColor.rgb(26, 86, 148); + private static final DocumentColor SURFACE = DocumentColor.rgb(238, 243, 249); + + @Test + void aCanvasNamesThePlacesRoomAndWidthItIsWrittenWithout() throws Exception { + 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)) + .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; " + + "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 + .addCanvas(360, 40, canvas -> canvas.position(new SpacerBuilder().height(40).build(), 0, 0)) + .addParagraph("Below")); + + assertThat(report.bySubject()).doesNotContainKey("CanvasLayerNode"); + } + + @Test + void aCanvasNothingFollowsMovesNothing() throws Exception { + DocxExportReport report = reportOf(page -> page + .addParagraph("Above") + .addCanvas(100, 80, canvas -> canvas.position(new ShapeBuilder().size(40, 40).fillColor(INK).build(), + 20, 20))); + + assertThat(report.bySubject()).doesNotContainKey("CanvasLayerNode"); + } + + @Test + void aCanvasInAStackOfOneLayerStandsInTheFlowToo() throws Exception { + // A stack of one layer lays nothing over anything; a template wraps a block in one. + DocxExportReport report = reportOf(page -> page + .addLayerStack(stack -> stack.layer(new com.demcha.compose.document.dsl.CanvasLayerBuilder(360, 80) + .position(new ParagraphBuilder().text("Caption").build(), 10, 30) + .build(), LayerAlign.TOP_LEFT)) + .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"); + } + + @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. + DocxExportReport report = reportOf(page -> page + .addTimeline(timeline -> timeline + .entry(com.demcha.compose.document.dsl.TimelineMarker.dot(8, INK), entry -> entry + .title("Senior Engineer").meta("2023 - Present")) + .entry(com.demcha.compose.document.dsl.TimelineMarker.dot(8, INK), entry -> entry + .title("Engineer").meta("2021 - 2023"))) + .addParagraph("Below")); + + assertThat(report.bySubject()).doesNotContainKey("CanvasLayerNode"); + } + + @Test + void aPanelBledToThePagesEdgesNamesItsBleed() throws Exception { + Consumer bled = page -> page.addSection(band -> band.fillColor(SURFACE) + .bleedToEdge(DocumentEdge.LEFT, DocumentEdge.RIGHT).addParagraph("A band to the edges")); + DocxExportReport report = reportOf(bled); + + assertThat(detailOf(report, "SectionNode")).isEqualTo("written as a panel; its bleed is not in the file, " + + "so its fill and borders stop at its box, not at the page's edge"); + assertThat(bodyOf(bled)).as("the file does not carry it").isEqualTo(bodyOf(page -> page + .addSection(band -> band.fillColor(SURFACE).addParagraph("A band to the edges")))); + } + + @Test + void aPanelInALayerIsNotBledByThePageEither() throws Exception { + // The page bleeds a section only in the flow it pages. + DocxExportReport report = reportOf(page -> page.addLayerStack(stack -> stack + .layer(new SectionBuilder().fillColor(SURFACE).bleedToEdge(DocumentEdge.LEFT, DocumentEdge.RIGHT) + .addParagraph("Card").build(), LayerAlign.TOP_LEFT))); + + assertThat(report.bySubject()).doesNotContainKey("SectionNode"); + } + + @Test + void anUnpaintedSectionsBleedPaintsNothing() throws Exception { + DocxExportReport report = reportOf(page -> page + .addSection(section -> section.bleedToEdge(DocumentEdge.LEFT).addParagraph("Nothing to bleed"))); + + assertThat(report.bySubject()).doesNotContainKey("SectionNode"); + } + + @Test + void aLineDrawnInTheFlowNamesTheKeepItIsWrittenWithout() throws Exception { + // Its drawing is anchored in a paragraph near it, and nothing keeps that paragraph with + // the next block: a page can end between them before a table, a paragraph, in a panel. + String lost = "; its keep with the next block is not carried, so a page can end between the two"; + DocxExportReport beforeATable = reportOf(page -> page + .addParagraph("Above") + .addLine(line -> line.vertical(30).thickness(1).color(INK).keepWithNext()) + .addTable(table -> table.columns(DocumentTableColumn.fixed(200)).row("Kept"))); + DocxExportReport beforeAParagraph = reportOf(page -> page + .addParagraph("Above") + .addLine(line -> line.vertical(30).thickness(1).color(INK).keepWithNext()) + .addParagraph("Kept")); + DocxExportReport inAPanel = reportOf(page -> page + .addSection(panel -> panel.fillColor(SURFACE) + .addLine(line -> line.vertical(30).thickness(1).color(INK).keepWithNext()) + .addParagraph("Kept"))); + + assertThat(detailOf(beforeATable, "LineNode")).startsWith("drawn as a shape").endsWith(lost); + assertThat(detailOf(beforeAParagraph, "LineNode")).startsWith("drawn as a shape").endsWith(lost); + assertThat(detailOf(inAPanel, "LineNode")).startsWith("drawn as a shape").endsWith(lost); + } + + @Test + void aLineKeptWhereThePageKeepsNothingNamesNoKeep() throws Exception { + // A row's cell is a box the page places, not its flow; a page break ends the page anyway. + DocxExportReport inARowCell = reportOf(page -> page.addRow(row -> row.weights(1, 1) + .addSection(section -> section + .addLine(line -> line.vertical(30).thickness(1).color(INK).keepWithNext()) + .addTable(table -> table.columns(DocumentTableColumn.fixed(100)).row("Kept"))) + .addParagraph("Beside"))); + DocxExportReport beforeABreak = reportOf(page -> page + .addParagraph("Above") + .addLine(line -> line.vertical(30).thickness(1).color(INK).keepWithNext()) + .addPageBreak(pageBreak -> { }) + .addParagraph("Next page")); + + assertThat(detailOf(inARowCell, "LineNode")).doesNotContain("keep"); + assertThat(detailOf(beforeABreak, "LineNode")).doesNotContain("keep"); + } + + @Test + void aPanelBledAcrossPagesNamesItsBleed() throws Exception { + // The page bleeds it toward the top of every page it stands on. + DocxExportReport report = reportOf(page -> page.addSection(band -> { + band.fillColor(SURFACE).bleedToEdge(DocumentEdge.TOP); + for (int line = 0; line < 60; line++) { + band.addParagraph("Line " + line + " of a band long enough to run onto a second page"); + } + })); + + assertThat(report.bySubject().get("SectionNode")).extracting(DocxExportReport.Note::detail) + .contains("written as a panel; its bleed is not in the file, so its fill and borders stop at its " + + "box, not at the page's edge"); + } + + @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 { + DocxExportReport report = reportOf(page -> page + .addCanvas(100, 80, canvas -> canvas.position(new ShapeBuilder().size(40, 40).fillColor(INK).build(), + 20, 20)) + .addPageBreak(pageBreak -> { }) + .addParagraph("Next page")); + + assertThat(report.bySubject()).doesNotContainKey("CanvasLayerNode"); + } + + @Test + void aCanvasStackingWhatItWritesFromItsCornerKeepsItsPlaces() throws Exception { + DocxExportReport report = reportOf(page -> page + .addCanvas(360, 50, canvas -> canvas + .position(new SpacerBuilder().height(20).build(), 0, 0) + .position(new SpacerBuilder().height(30).build(), 0, 20)) + .addParagraph("Below")); + + assertThat(report.bySubject()).doesNotContainKey("CanvasLayerNode"); + } + + @Test + void aColumnFixedNarrowerThanItsBandNamesItsWidth() throws Exception { + // The left column's text runs to its margin, so the stack spans the page; the right + // column's band runs from its margin to the stack's edge, wider than it is fixed at. + DocxExportReport report = reportOf(600, page -> page.addLayerStack(stack -> stack + .layer(new SectionBuilder().margin(new DocumentInsets(0, 300, 0, 0)) + .addParagraph("A left column whose text is long enough to run to the margin that " + + "keeps it clear of the right one").build(), LayerAlign.TOP_LEFT) + .layer(new SectionBuilder().name("Right").margin(new DocumentInsets(0, 0, 0, 280)).fixedWidth(150) + .addParagraph("A right column long enough to wrap at its fixed width of one hundred and " + + "fifty points").build(), LayerAlign.TOP_LEFT))); + + assertThat(detailOf(report, "SectionNode")).isEqualTo("written as a column of its layer stack; its fixed " + + "width is not in the file, so its paragraphs and lists run the width of its column"); + } + + private static String detailOf(DocxExportReport report, String subject) { + List notes = report.bySubject().get(subject); + assertThat(notes).as("the note on the %s", subject).isNotNull().hasSize(1); + return notes.get(0).detail(); + } + + private static DocxExportReport reportOf(Consumer content) throws Exception { + return reportOf(400, content); + } + + private static DocxExportReport reportOf(double pageWidth, Consumer content) throws Exception { + AtomicReference captured = new AtomicReference<>(); + try (DocumentSession session = GraphCompose.document() + .pageSize(pageWidth, 600) + .margin(DocumentInsets.of(20)) + .create()) { + session.pageFlow(content::accept); + session.export(new DocxSemanticBackend(captured::set)); + } + assertThat(captured.get()).as("the sink is called once the bytes exist").isNotNull(); + return captured.get(); + } + + private static String bodyOf(Consumer content) throws Exception { + try (DocumentSession session = GraphCompose.document().pageSize(400, 600).margin(DocumentInsets.of(20)).create()) { + session.pageFlow(content::accept); + try (XWPFDocument document = new XWPFDocument( + new ByteArrayInputStream(session.export(new DocxSemanticBackend())))) { + return document.getDocument().getBody().xmlText(); + } + } + } +} 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 5d191f67c..fd0b2f88c 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 @@ -74,8 +74,9 @@ private enum Fate { /** Not in the Word file, and named in the export's report when the author set it. */ REPORTED, /** - * Nothing for a Word file to carry: a name the report addresses the node by, or an - * identity the layout resolves a wrapper's place by. + * Nothing for a Word file to carry: a name the report addresses the node by, an + * identity the layout resolves a wrapper's place by, or a field the page itself does + * not apply. */ INERT, /** Lost, in some or all cases, without a report note yet; the entry says what. */ @@ -106,18 +107,18 @@ private record Entry(Fate fate, String note) { "margin:REPORTED:its left side; its right moves nothing in a paragraph set from the left", "transform:REPORTED", "anchor:WRITTEN"); node(CanvasLayerNode.class, "name:INERT", - "width:GAP:the room the canvas holds in the flow", - "height:GAP:the room the canvas holds in the flow", - "placements:GAP:where its text, pictures and tables stand; they are written one after another", - "clipPolicy:GAP:the clip", "padding:WRITTEN", "margin:WRITTEN"); + "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", + "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", "margin:REPORTED:in the chart's note; above and below; below a block a band or a column measures from, written", "padding:REPORTED:in the chart's note; above and below; below a block a band or a column measures from, written"); node(ContainerNode.class, "name:INERT", "children:WRITTEN", "spacing:WRITTEN", "padding:WRITTEN", "margin:WRITTEN", "fillColor:WRITTEN", "stroke:WRITTEN", "cornerRadius:REPORTED", "borders:WRITTEN", "anchor:REPORTED:as a layer stack's column, which has no bookmark; in the flow it is bookmarked", - "bookmarkOptions:REPORTED", "flowWidth:GAP:the width of a container written as a layer stack's column; " - + "an unpainted one's, and a panel's in a table cell, are reported"); + "bookmarkOptions:REPORTED", "flowWidth:REPORTED:of an unpainted one, a panel in a table cell and a layer stack's column"); node(EllipseNode.class, "name:INERT", "width:WRITTEN", "height:WRITTEN", "fillColor:WRITTEN", "stroke:WRITTEN", "linkTarget:REPORTED", "bookmarkOptions:REPORTED", "padding:WRITTEN", "margin:WRITTEN", "transform:REPORTED", @@ -135,7 +136,7 @@ private record Entry(Fate fate, String note) { "bookmarkOptions:REPORTED", "padding:WRITTEN", "margin:WRITTEN", "transform:REPORTED", "dashPattern:REPORTED", "anchor:REPORTED:drawn; a rule in the flow is bookmarked", "lineCap:REPORTED:a cap other than butt; whether an editor ends a drawn butt line flat is not measured", - "fillWidth:WRITTEN", "keepWithNext:GAP:the keep of a line drawn as a shape"); + "fillWidth:WRITTEN", "keepWithNext:REPORTED:of a line drawn in the flow the page keeps blocks together in"); node(ListNode.class, "name:INERT", "items:WRITTEN", "nestedItems:WRITTEN", "marker:WRITTEN", "textStyle:WRITTEN", "align:GAP:a centred or right-aligned list is written flush left", "lineSpacing:GAP:the gap between an item's lines where the layout's item count differs", @@ -177,10 +178,9 @@ private record Entry(Fate fate, String note) { "margin:WRITTEN", "fillColor:WRITTEN", "stroke:WRITTEN", "cornerRadius:REPORTED", "borders:WRITTEN", "keepTogether:WRITTEN", "anchor:REPORTED:as a layer stack's column, which has no bookmark; in the flow it is bookmarked", - "bleed:GAP:the paint's bleed past the section", + "bleed:REPORTED:of a panel the page bleeds, in the flow it pages", "bookmarkOptions:REPORTED", "keepWithNext:WRITTEN", - "flowWidth:GAP:the width of a section written as a layer stack's column; " - + "an unpainted one's, and a panel's in a table cell, are reported"); + "flowWidth:REPORTED:of an unpainted one, a panel in a table cell and a layer stack's column"); node(ShapeContainerNode.class, "name:INERT", "outline:WRITTEN", "layers:WRITTEN", "clipPolicy:GAP:the clip of a container written as a badge, a line pair or over the flow", "fillColor:WRITTEN", "stroke:WRITTEN", "padding:WRITTEN", "margin:WRITTEN",