diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b33d552e..9b69abcd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,40 @@ follow semantic versioning; release dates are ISO 8601. ### Fixed +- **A composite node inside a composed table cell renders its children.** + `DocumentTableCell.node(...)` holding a `SectionNode`, `ContainerNode`, + `RowNode` or `LayerStackNode` measured the child, reserved its full height, + and then drew nothing inside it — a correctly-sized blank hole in the table. + A composite leaves its children to the compiler and emits only its own + decoration from `emitFragments`, so dispatching a composed cell straight at + the child's `emitFragments` picked up the section background and dropped + every paragraph under it. The cell now lays the child's whole sub-tree out + inside the cell box, with the same column / row / stack layout the sub-tree + gets anywhere else on the page. Leaf children (paragraph, list) and nested + tables already worked and are unchanged. The row stays atomic: a composed + cell still does not split across a page break. + +- **A row nested in a fixed rectangle keeps its horizontal band.** A `RowNode` + inside a `LayerStackNode` layer — allowed since 1.6.2 — stacked its children + downwards instead of seating them side by side, and because the band was + measured as one row tall, every child after the first spilled out of the + layer. The fixed-rectangle walk had no horizontal branch at all: it is a + vertical y-cursor, right for a section or a container and wrong for a row. + It now resolves slot widths through the same `RowSlots` path the page-level + row band uses, so a nested row honours weights, fixed columns, flex + arrangement and vertical alignment identically. Vertical composites in a + fixed rectangle are unchanged. Found while composing a row into a table + cell, which is the second rectangle this walk fills. + + **Behaviour note:** a row nested *directly inside another row* in a fixed + rectangle now raises the same `IllegalStateException` the page-level row + band has always raised (`"cannot contain a nested horizontal row"`, which + names the fix: wrap the inner row in its own layer). It previously + produced a layout instead — but not a usable one: with a two-child inner + row inside a two-child outer row in a layer, two of the three leaves + landed on the same point, 17pt below the layer's own bottom edge. Wrapping + the inner row in its own `LayerStackNode` layer lays it out correctly. + - **The SVG reader honours the opacity family.** `opacity`, `fill-opacity` and `stroke-opacity` — attribute or `style=""`, number or percentage, with SVG's inheritance for the paint slots and composition for group `opacity` — now @@ -69,6 +103,17 @@ follow semantic versioning; release dates are ISO 8601. ### Documentation +- **`DocumentTableCell.text("a\nb")` is one line, and now says so.** The + advanced-tables recipe demonstrated a multi-line cell by putting `\n` inside + `text(...)`, which renders as a single line — the newline is whitespace + between two words there. The recipe and the `DocumentTableCell` Javadoc now + name the three cell shapes explicitly: `text(...)` for one line, + `lines(...)` for several, `node(...)` for any registered node (and + `ParagraphNode` *does* honour `\n` as a hard break, inside a cell as + anywhere else). The two examples that showed the misleading form were + switched to `lines(...)`, and the composed-cell showcase gained a section + and a row inside table cells. + - **The SVG Javadoc stopped describing a younger reader.** `SvgGradients` claimed focal radials and `stop-opacity` are "loudly refused" — both degrade deliberately (centred-radial approximation, opaque stops, alpha-only overlay diff --git a/assets/readme/examples/composed-table-cell-showcase.pdf b/assets/readme/examples/composed-table-cell-showcase.pdf index a51388741..e9e827a80 100644 Binary files a/assets/readme/examples/composed-table-cell-showcase.pdf and b/assets/readme/examples/composed-table-cell-showcase.pdf differ diff --git a/assets/readme/examples/table-advanced.pdf b/assets/readme/examples/table-advanced.pdf index 3e426d5df..6f983e3ce 100644 Binary files a/assets/readme/examples/table-advanced.pdf and b/assets/readme/examples/table-advanced.pdf differ diff --git a/core/src/main/java/com/demcha/compose/document/layout/DocumentLayoutPassContext.java b/core/src/main/java/com/demcha/compose/document/layout/DocumentLayoutPassContext.java index f190cbdc1..d3ca9bef4 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/DocumentLayoutPassContext.java +++ b/core/src/main/java/com/demcha/compose/document/layout/DocumentLayoutPassContext.java @@ -4,6 +4,7 @@ import com.demcha.compose.engine.measurement.TextMeasurementSystem; import com.demcha.compose.font.FontLibrary; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -30,6 +31,14 @@ public final class DocumentLayoutPassContext implements PrepareContext, Fragment private final PageGeometry pageGeometry; private final Map nodeStartPages; private final Map> preparedNodes = new HashMap<>(); + /** + * Compiler used to lay out a composite child inside a box its parent owns + * (see {@link #emitChildFragments}). Created on first use so a pass with no + * composed cells never builds one; a pass is single-threaded, and the + * compiler carries no state beyond the registry, so one instance serves + * every box in the pass. + */ + private LayoutCompiler subtreeCompiler; /** * Creates a layout-pass context with no resolved page numbers — the first @@ -165,10 +174,61 @@ public List emitChildFragments( FragmentPlacement placement) { Objects.requireNonNull(child, "child"); Objects.requireNonNull(placement, "placement"); + if (child.isComposite()) { + return emitCompositeSubtree((PreparedNode) child, placement); + } NodeDefinition definition = (NodeDefinition) registry.definitionFor(child.node()); return definition.emitFragments(child, this, placement); } + /** + * Lays out a composite child's whole sub-tree inside {@code placement} and + * returns its fragments in the placement's local coordinate space. + * + *

A composite's own {@code emitFragments} yields nothing but its + * decoration — the section background, the container border — because the + * compiler, not the definition, walks {@link NodeDefinition#children}. + * Dispatching to it alone would leave the caller with a correctly measured + * but empty box. The fixed-box walk applies the same column / row / stack + * layout the sub-tree would get at document level, then the absolute + * coordinates it produces are rebased onto the placement so the caller can + * translate them into its own fragment space exactly as it does for a leaf + * child.

+ */ + private List emitCompositeSubtree(PreparedNode child, + FragmentPlacement placement) { + if (subtreeCompiler == null) { + subtreeCompiler = new LayoutCompiler(registry); + } + List placed = subtreeCompiler.compileFixedBoxSubtree( + child, + placement.parentPath(), + placement.childIndex(), + placement.depth(), + placement.x(), + placement.y() + placement.height(), + placement.width(), + placement.pageIndex(), + canvas, + this, + this); + if (placed.isEmpty()) { + return List.of(); + } + List local = new ArrayList<>(placed.size()); + for (PlacedFragment fragment : placed) { + local.add(new LayoutFragment( + fragment.path(), + fragment.fragmentIndex(), + fragment.x() - placement.x(), + fragment.y() - placement.y(), + fragment.width(), + fragment.height(), + fragment.payload())); + } + return List.copyOf(local); + } + private long normalizeWidth(double value) { return Math.round(value * 1_000.0); } diff --git a/core/src/main/java/com/demcha/compose/document/layout/FragmentContext.java b/core/src/main/java/com/demcha/compose/document/layout/FragmentContext.java index d15002b39..a69b4cc66 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/FragmentContext.java +++ b/core/src/main/java/com/demcha/compose/document/layout/FragmentContext.java @@ -42,8 +42,8 @@ default boolean markdownEnabled() { } /** - * Dispatches fragment emission to a previously-prepared child node - * via the active {@code NodeRegistry}. + * Emits the fragments of a previously-prepared child sub-tree, laid out + * inside {@code placement}. * *

Used by composite primitives (e.g. {@code TableNode} cells with * {@code content}) that hold a prepared child sub-tree and need to @@ -53,12 +53,23 @@ default boolean markdownEnabled() { * {@code FragmentContext} implementations have to opt-in to the * recursion.

* + *

A leaf child is dispatched straight to its + * {@link NodeDefinition#emitFragments}. A composite child is laid + * out whole: its own {@code emitFragments} yields nothing but decoration + * (a section background, a container border) because the compiler, not the + * definition, walks {@link NodeDefinition#children} — so the implementation + * seats the sub-tree inside the placement with the same column / row / + * stack layout it would get at document level and returns every fragment + * the walk produced, not just the child's own. The returned fragments are + * local to {@code placement}, as for a leaf, so callers translate them + * into their own fragment space the same way either way.

+ * * @param child prepared child node previously obtained from * {@link PrepareContext#prepare(DocumentNode, BoxConstraints)} * @param placement placement assigned to the child within the * composite parent's geometry * @param child node type - * @return fragments emitted by the child's {@code NodeDefinition} + * @return fragments of the child sub-tree, local to {@code placement} * @throws UnsupportedOperationException when the * {@code FragmentContext} implementation does not back * child-fragment emission diff --git a/core/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java b/core/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java index f68b0e94e..4c7af1cdd 100644 --- a/core/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java +++ b/core/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java @@ -767,6 +767,68 @@ void placeStackLayer(DocumentNode child, layerCtx); } + /** + * Lays out a prepared sub-tree inside a fixed rectangle and returns the + * fragments it produced, without touching the surrounding document's + * placement state. + * + *

Used by primitives that own a child sub-tree inside their own + * geometry — a {@code TableNode} cell built with + * {@link com.demcha.compose.document.table.DocumentTableCell#node} — and + * therefore have to place that sub-tree themselves during fragment + * emission. Dispatching to the child's own + * {@link NodeDefinition#emitFragments} is not enough for a composite + * child: a composite emits only its decoration and leaves its children + * to the compiler, so the sub-tree would be measured, reserved, and then + * silently dropped. Routing through {@link #compileNodeInFixedSlot} gives + * such a child the same column / row / stack layout it gets anywhere else + * in the document.

+ * + *

The placed nodes the walk produces are discarded: the box already + * exists inside its owner's geometry, so re-publishing its interior into + * the document's semantic node list would double-count it.

+ * + * @param prepared prepared sub-tree root + * @param parentPath path of the node that owns the box + * @param childIndex index of the box within its owner + * @param depth depth of the box within its owner + * @param slotX left edge of the box, in absolute page coordinates + * @param slotTopY top edge of the box, in absolute page coordinates + * @param slotWidth width of the box + * @param pageIndex page the box lands on + * @param canvas active layout canvas + * @param prepareContext prepare context used to (re)measure descendants + * @param fragmentContext fragment context forwarded to descendant definitions + * @return fragments placed inside the box, in absolute page coordinates + */ + List compileFixedBoxSubtree(PreparedNode prepared, + String parentPath, + int childIndex, + int depth, + double slotX, + double slotTopY, + double slotWidth, + int pageIndex, + LayoutCanvas canvas, + PrepareContext prepareContext, + FragmentContext fragmentContext) { + List discardedNodes = new ArrayList<>(); + List fragments = new ArrayList<>(); + PlacementContext ctx = new FixedSlotPlacementContext( + pageIndex, canvas, prepareContext, fragmentContext, discardedNodes, fragments); + compileNodeInFixedSlot( + prepared, + parentPath, + childIndex, + depth, + slotX, + slotTopY, + slotWidth, + FixedSlotKind.FIXED_BOX_SLOT, + ctx); + return List.copyOf(fragments); + } + /** * Compiles a composite or leaf node inside a fixed slot. * @@ -944,26 +1006,41 @@ private double compileNodeInFixedSlot(PreparedNode prepared, double childRegionWidth = Math.max(0.0, availableWidth - padding.horizontal()); double childTopY = placementTopY - padding.top(); - for (int i = 0; i < children.size(); i++) { - DocumentNode child = children.get(i); - PreparedNode childPrepared = - prepareForRegionWidth(prepareContext, child, childRegionWidth); - // Propagate the parent's slot kind so a STACK layer - // descendant (column → row → ...) keeps the relaxed - // validation policy all the way down. - double consumed = compileNodeInFixedSlot( - childPrepared, + if (layoutSpec.axis() == CompositeLayoutSpec.Axis.HORIZONTAL) { + placeRowBandInFixedSlot( + node, + children, + layoutSpec, + semanticName, path, - i, - depth + 1, + depth, childRegionX, childTopY, childRegionWidth, - kind, + measure.height() - padding.vertical(), ctx); - childTopY -= consumed; - if (i < children.size() - 1) { - childTopY -= layoutSpec.spacing(); + } else { + for (int i = 0; i < children.size(); i++) { + DocumentNode child = children.get(i); + PreparedNode childPrepared = + prepareForRegionWidth(prepareContext, child, childRegionWidth); + // Propagate the parent's slot kind so a STACK layer + // descendant (column → row → ...) keeps the relaxed + // validation policy all the way down. + double consumed = compileNodeInFixedSlot( + childPrepared, + path, + i, + depth + 1, + childRegionX, + childTopY, + childRegionWidth, + kind, + ctx); + childTopY -= consumed; + if (i < children.size() - 1) { + childTopY -= layoutSpec.spacing(); + } } } @@ -1019,6 +1096,91 @@ private double compileNodeInFixedSlot(PreparedNode prepared, return measure.height() + margin.vertical(); } + /** + * Seats a horizontal composite's children side by side inside a fixed slot. + * + *

The fixed-slot walk is otherwise a vertical y-cursor, which is the + * right model for a section or a container but the wrong one for a row: a + * row's children share one band and split its width. Without this branch a + * row nested in a fixed rectangle — a + * {@link com.demcha.compose.document.node.LayerStackNode} layer, or a + * {@code TableNode} cell built with + * {@link com.demcha.compose.document.table.DocumentTableCell#node} — stacks + * its children downwards and overflows the rectangle by the height of every + * child after the first, because the band was measured as one row tall.

+ * + *

Slot widths come from the same {@link RowSlots#resolveLayout} the + * page-flow row band uses, so a fixed-slot row honours weights, fixed + * columns, flex arrangement and vertical alignment identically. Children are + * compiled as {@link FixedSlotKind#ROW_SLOT} so a nested horizontal row is + * rejected here exactly as it is at page level.

+ * + * @param node the horizontal composite being seated + * @param children its children, in source order + * @param layoutSpec the composite's resolved layout spec + * @param semanticName name used in the slot-resolution diagnostics + * @param path layout path of the composite + * @param depth depth of the composite; children sit one below + * @param bandStartX left edge of the band's content area + * @param bandTopY top edge of the band's content area + * @param bandWidth width available to the children + * @param bandContentHeight height of the band, used to seat non-TOP children + * @param ctx placement context the children append to + */ + private void placeRowBandInFixedSlot(DocumentNode node, + List children, + CompositeLayoutSpec layoutSpec, + String semanticName, + String path, + int depth, + double bandStartX, + double bandTopY, + double bandWidth, + double bandContentHeight, + PlacementContext ctx) { + if (children.isEmpty()) { + return; + } + PrepareContext prepareContext = ctx.prepareContext(); + RowSlots.SlotLayout slotLayout = RowSlots.resolveLayout( + node, children, layoutSpec, bandWidth, prepareContext, semanticName); + double[] slotWidths = slotLayout.widths(); + RowVerticalAlign verticalAlign = node instanceof RowNode rowNode + ? rowNode.verticalAlign() : RowVerticalAlign.TOP; + double cursorX = bandStartX + slotLayout.leading(); + + for (int index = 0; index < children.size(); index++) { + DocumentNode child = children.get(index); + Margin childMargin = toMargin(child.margin()); + double slotWidth = slotWidths[index]; + double childInnerWidth = Math.max(0.0, slotWidth - childMargin.horizontal()); + PreparedNode childPrepared = + prepareForRegionWidth(prepareContext, child, childInnerWidth); + + // Cross-axis seating, identical to the page-level row band: TOP + // yields offset 0.0, so a TOP row places exactly where the plain + // vertical walk used to put its first child. + double verticalOffset = verticalAlign == RowVerticalAlign.TOP + ? 0.0 + : (bandContentHeight - childMargin.vertical() - childPrepared.measureResult().height()) + * (verticalAlign == RowVerticalAlign.CENTER ? 0.5 : 1.0); + + compileNodeInFixedSlot( + childPrepared, + path, + index, + depth + 1, + cursorX, + bandTopY - verticalOffset, + slotWidth, + FixedSlotKind.ROW_SLOT, + ctx); + + cursorX += slotWidth + layoutSpec.spacing() + + (index < children.size() - 1 ? slotLayout.extraGap() : 0.0); + } + } + private PreparedNode prepareForRegionWidth(PrepareContext prepareContext, DocumentNode node, double regionWidth) { @@ -1163,7 +1325,15 @@ private enum FixedSlotKind { /** * Child sits inside a {@link LayerStackNode} layer rectangle. */ - STACK_LAYER_SLOT + STACK_LAYER_SLOT, + /** + * Child sits inside a rectangle a primitive owns within its own + * geometry — a composed {@code TableNode} cell. Like a stack layer, + * the surrounding rectangle is already fixed, so a horizontal row + * inside it is a normal column-row rather than a band competing with + * a parent row. + */ + FIXED_BOX_SLOT } } diff --git a/core/src/main/java/com/demcha/compose/document/table/DocumentTableCell.java b/core/src/main/java/com/demcha/compose/document/table/DocumentTableCell.java index 6e80623ff..aa98b1f3f 100644 --- a/core/src/main/java/com/demcha/compose/document/table/DocumentTableCell.java +++ b/core/src/main/java/com/demcha/compose/document/table/DocumentTableCell.java @@ -100,7 +100,14 @@ public DocumentTableCell(List lines, DocumentTableStyle style) { /** * Creates a one-line text cell. * - * @param text cell text + *

The argument is the line. A {@code "\n"} inside it does not + * break the cell in two — the cell carries one line and the newline lays + * out as whitespace between two words. Use {@link #lines(String...)} when + * the break belongs to the content, or {@link #node(DocumentNode)} with a + * {@code ParagraphNode}, which does honour {@code "\n"} as a hard + * break.

+ * + * @param text cell text, rendered as exactly one line * @return table cell */ public static DocumentTableCell text(String text) { @@ -108,9 +115,12 @@ public static DocumentTableCell text(String text) { } /** - * Creates a multi-line text cell. + * Creates a multi-line text cell — one argument per rendered line. + * + *

This is how a plain-text cell breaks across lines; + * {@code text("a\nb")} does not.

* - * @param lines text lines + * @param lines text lines, one per rendered line * @return table cell */ public static DocumentTableCell lines(String... lines) { @@ -127,6 +137,13 @@ public static DocumentTableCell lines(String... lines) { * the cell's bounds; the cell's own {@code lines} are unused * when {@code content} is non-null. * + *

Any registered node type works, leaf or composite: a paragraph, a + * list, a nested table, and equally a {@code SectionNode}, + * {@code ContainerNode}, {@code RowNode}, or {@code LayerStackNode} — + * the cell lays out the child's whole sub-tree, not just the child's own + * decoration. The row stays atomic, so the child must fit within one + * page's content area.

+ * * @param child composed child node, must not be {@code null} * @return table cell carrying the composed child * @throws NullPointerException when {@code child} is {@code null} diff --git a/docs/recipes/tables.md b/docs/recipes/tables.md index a7a4f4d91..d0bf4173f 100644 --- a/docs/recipes/tables.md +++ b/docs/recipes/tables.md @@ -8,6 +8,8 @@ patterns: | --- | --- | | Column span | `DocumentTableCell.text(...).colSpan(int)` | | Row span | `DocumentTableCell.text(...).rowSpan(int)` | +| Several lines in one cell | `DocumentTableCell.lines(String...)` | +| Any node in one cell | `DocumentTableCell.node(DocumentNode)` | | Header row alias | `TableBuilder.headerRow(String...)` | | Totals row | `TableBuilder.totalRow(String...)` | | Zebra rows | `TableBuilder.zebra(odd, even)` | @@ -18,6 +20,46 @@ zebra striping on the data rows, a bold totals row at the bottom, and a header that re-emits at the top of every continuation page when the table paginates. +## Cell content — one line, several lines, or a node + +Three factories, three shapes. Pick by what the cell holds, not by +what the text looks like: + + +```java +// One line. The argument is the line — a "\n" inside it is not a +// line break, it is just whitespace between two words. +DocumentTableCell.text("Design review"); + +// Several lines. One argument per line. +DocumentTableCell.lines("Design review", "2 h 30 m"); + +// Any registered node: paragraph, list, section, container, row, +// layer stack, or a nested table. +DocumentTableCell.node(new ParagraphNode( + "Note", "**Blocked** — waiting on legal", + DocumentTextStyle.DEFAULT, TextAlign.LEFT, 0.0, + DocumentInsets.zero(), DocumentInsets.zero())); +``` + +`text("Design review\n2 h 30 m")` renders as the single line +`Design review 2 h 30 m`. That is the plain-text cell's contract: a +cell's line list is what the author passed, and `text(...)` passes +exactly one. Use `lines(...)` when the break is part of the content, +or `node(...)` when the cell needs styling, markdown, or structure +around it. + +Paragraph content is a different story: `ParagraphNode` **does** treat +`\n` as a hard line break, inside a cell exactly as it does anywhere +else on the page. So a composed cell built from a paragraph whose text +contains `\n` breaks where the author wrote it. + +A composed cell reserves the child's measured height and renders the +child's whole sub-tree, so a `SectionNode` of three paragraphs is three +paragraphs in the cell, not just the section's background. The row stays +atomic: a composed cell does not split across a page break, so keep the +child shorter than one page's content area. + ## Row span — merge a cell vertically Spanning cells declare how many rows they cover via `rowSpan(int)`. @@ -38,7 +80,7 @@ addTable(table -> table // Row 0: Tall middle cell spans BOTH rows below it. .rowCells( DocumentTableCell.text("A0"), - DocumentTableCell.text("Tall middle\n(spans 3 rows)").rowSpan(3), + DocumentTableCell.lines("Tall middle", "(spans 3 rows)").rowSpan(3), DocumentTableCell.text("C0")) // Row 1: only A1 + C1 — middle is occupied by the spanning cell. .rowCells( diff --git a/examples/README.md b/examples/README.md index 795e11568..962731f46 100644 --- a/examples/README.md +++ b/examples/README.md @@ -123,7 +123,7 @@ are with the canonical DSL, then jump to its detailed section below. | [Colour emoji](#colour-emoji) | `RichText.emoji(":star:", size)` — GitHub-style shortcodes resolve to inline vector glyphs via the `graph-compose-emoji` artifact; unknown codes fall back to literal text | [PDF](../assets/readme/examples/emoji-shortcodes.pdf) · [Source](src/main/java/com/demcha/examples/features/text/EmojiShortcodeExample.java) | | [Section presets](#section-presets) | `pageBackground`, `band`, `softPanel`, `accentLeft / Right / Top / Bottom`, per-corner `DocumentCornerRadius` | [PDF](../assets/readme/examples/section-presets.pdf) · [Source](src/main/java/com/demcha/examples/features/text/SectionPresetsExample.java) | | Nested lists | `ListBuilder.addItem(label, Consumer)` — depth cascade, per-depth markers, mixed flat / nested authoring | [PDF](../assets/readme/examples/nested-list-showcase.pdf) · [Source](src/main/java/com/demcha/examples/features/lists/NestedListExample.java) | -| Composed table cells | `DocumentTableCell.node(DocumentNode)` — paragraphs, lists, sub-tables inside cells with two-pass measurement | [PDF](../assets/readme/examples/composed-table-cell-showcase.pdf) · [Source](src/main/java/com/demcha/examples/features/tables/ComposedTableCellExample.java) | +| Composed table cells | `DocumentTableCell.node(DocumentNode)` — paragraphs, lists, sub-tables, sections and rows inside cells with two-pass measurement | [PDF](../assets/readme/examples/composed-table-cell-showcase.pdf) · [Source](src/main/java/com/demcha/examples/features/tables/ComposedTableCellExample.java) | | [Inline-code column wrap](#inline-code-column-wrap) | A long `inlineCode(...)` coordinate breaks at its `. : / -` seams inside a narrow **fixed** column and an **auto** column grows to fit it on one line | [PDF](../assets/readme/examples/inline-code-column-wrap.pdf) · [Source](src/main/java/com/demcha/examples/features/tables/InlineCodeColumnWrapExample.java) | | Canvas layer (free placement) | `CanvasLayerNode` — pixel-precise `(x, y)` placement of children inside a fixed bounding box, with `ClipPolicy` clipping | [PDF](../assets/readme/examples/canvas-layer-showcase.pdf) · [Source](src/main/java/com/demcha/examples/features/canvas/CanvasLayerExample.java) | | [Transforms](#transforms) | `rotate`, `scale`, and per-layer `zIndex` swap | [PDF](../assets/readme/examples/transforms.pdf) · [Source](src/main/java/com/demcha/examples/features/transforms/TransformsExample.java) | diff --git a/examples/src/main/java/com/demcha/examples/features/tables/ComposedTableCellExample.java b/examples/src/main/java/com/demcha/examples/features/tables/ComposedTableCellExample.java index 1413e0ca4..90196be37 100644 --- a/examples/src/main/java/com/demcha/examples/features/tables/ComposedTableCellExample.java +++ b/examples/src/main/java/com/demcha/examples/features/tables/ComposedTableCellExample.java @@ -6,9 +6,12 @@ import com.demcha.compose.document.node.ListMarker; import com.demcha.compose.document.node.ListNode; import com.demcha.compose.document.node.ParagraphNode; +import com.demcha.compose.document.node.RowNode; +import com.demcha.compose.document.node.SectionNode; import com.demcha.compose.document.node.TableNode; import com.demcha.compose.document.node.TextAlign; import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentCornerRadius; import com.demcha.compose.document.style.DocumentInsets; import com.demcha.compose.document.style.DocumentStroke; import com.demcha.compose.document.style.DocumentTextDecoration; @@ -268,6 +271,65 @@ public static Path generate() throws Exception { DocumentInsets.zero(), 1)); + // 4) Composite node in a cell — the child's whole sub-tree renders. + document.pageFlow() + .name("CompositeCellSection") + .spacing(6) + .addParagraph("4. Composite nodes in a cell", sectionHeading) + .addParagraph( + "A SectionNode or RowNode owns its children rather than drawing them " + + "itself, so a composed cell lays out the child's whole sub-tree: " + + "the section stacks its paragraphs, the row splits its band.", caption) + .build(); + + document.add(new TableNode( + "CompositeCellTable", + List.of(DocumentTableColumn.fixed(244), DocumentTableColumn.fixed(244)), + List.of( + List.of( + DocumentTableCell.text("Section in a cell").withStyle(headerStyle), + DocumentTableCell.text("Row in a cell").withStyle(headerStyle)), + List.of( + DocumentTableCell.node(new SectionNode( + "CellSection", + List.of( + new ParagraphNode("SectionTitle", + "**Renewal readiness**", + body, TextAlign.LEFT, 1.0, + DocumentInsets.zero(), DocumentInsets.zero()), + new ParagraphNode("SectionDetail", + "Contracts countersigned, pricing locked, " + + "handover deck in review.", + body, TextAlign.LEFT, 1.0, + DocumentInsets.zero(), DocumentInsets.zero())), + 4.0, + DocumentInsets.zero(), DocumentInsets.zero(), + null, null)) + .withStyle(bodyCellStyle), + DocumentTableCell.node(new RowNode( + "CellRow", + List.of( + new ParagraphNode("RowLeft", "**Owner**\nLegal", + body, TextAlign.LEFT, 1.0, + DocumentInsets.zero(), DocumentInsets.zero()), + new ParagraphNode("RowRight", "**Due**\nNov 28", + body, TextAlign.LEFT, 1.0, + DocumentInsets.zero(), DocumentInsets.zero())), + List.of(1.0, 1.0), + 8.0, + DocumentInsets.zero(), DocumentInsets.zero(), + null, null, DocumentCornerRadius.ZERO)) + .withStyle(tintedCellStyle))), + bodyCellStyle, + Map.of(), + Map.of(), + 488.0, + null, + null, + DocumentInsets.zero(), + DocumentInsets.zero(), + 1)); + document.buildPdf(); } return outputFile; diff --git a/examples/src/main/java/com/demcha/examples/features/tables/TableAdvancedExample.java b/examples/src/main/java/com/demcha/examples/features/tables/TableAdvancedExample.java index 823a1c30e..b06d50dc2 100644 --- a/examples/src/main/java/com/demcha/examples/features/tables/TableAdvancedExample.java +++ b/examples/src/main/java/com/demcha/examples/features/tables/TableAdvancedExample.java @@ -115,7 +115,9 @@ public static Path generate() throws Exception { .defaultCellStyle(bordered) .rowCells( DocumentTableCell.text("Q1"), - DocumentTableCell.text("Quarterly note\nspans the next two rows so the\nsidebar text breathes") + DocumentTableCell.lines("Quarterly note", + "spans the next two rows so the", + "sidebar text breathes") .rowSpan(3) .withStyle(mergedNote), DocumentTableCell.text("$1,200")) diff --git a/qa/src/test/java/com/demcha/compose/document/api/FooterPageNumberingOverflowTest.java b/qa/src/test/java/com/demcha/compose/document/api/FooterPageNumberingOverflowTest.java new file mode 100644 index 000000000..7b6d61935 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/api/FooterPageNumberingOverflowTest.java @@ -0,0 +1,138 @@ +package com.demcha.compose.document.api; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.node.TableNode; +import com.demcha.compose.document.output.DocumentHeaderFooter; +import com.demcha.compose.document.output.DocumentHeaderFooterZone; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.table.DocumentTableCell; +import com.demcha.compose.document.table.DocumentTableColumn; +import com.demcha.compose.document.table.DocumentTableStyle; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@code Page {page} of {pages}} on an invoice-shaped document whose row count + * decides the page count. + * + *

{@code {pages}} is a document-wide total, so it can only be right once + * pagination has settled — a footer stamped from a first-pass estimate would + * still read plausibly ("Page 1 of 3") while being wrong. The cases below drive + * the page count purely through table overflow and then read every page's + * footer out of the rendered PDF, so both halves of the token pair are pinned + * against the page count the backend actually produced.

+ */ +class FooterPageNumberingOverflowTest { + + @ParameterizedTest(name = "{0} invoice rows number every page of {1}") + @CsvSource({ + "1, 1", + "12, 2", + "40, 5" + }) + void tableOverflowNumbersEveryPage(int rowCount, int expectedPages) throws Exception { + List> rows = new ArrayList<>(rowCount); + for (int index = 1; index <= rowCount; index++) { + rows.add(List.of( + DocumentTableCell.text("Line item " + index), + DocumentTableCell.text("$" + (index * 10)))); + } + + byte[] pdfBytes; + try (DocumentSession session = GraphCompose.document() + .pageSize(400, 220) + .margin(DocumentInsets.of(20)) + .create()) { + session.footer(DocumentHeaderFooter.builder() + .zone(DocumentHeaderFooterZone.FOOTER) + .centerText("Page {page} of {pages}") + .build()); + session.add(new TableNode( + "Invoice", + List.of(DocumentTableColumn.fixed(200), DocumentTableColumn.fixed(140)), + rows, + DocumentTableStyle.empty(), + 340.0, + DocumentInsets.zero(), + DocumentInsets.zero())); + pdfBytes = session.toPdfBytes(); + } + + try (PDDocument document = Loader.loadPDF(pdfBytes)) { + assertThat(document.getNumberOfPages()) + .describedAs("%d rows must overflow onto %d pages", rowCount, expectedPages) + .isEqualTo(expectedPages); + + PDFTextStripper stripper = new PDFTextStripper(); + for (int page = 1; page <= expectedPages; page++) { + stripper.setStartPage(page); + stripper.setEndPage(page); + assertThat(stripper.getText(document)) + .describedAs("page %d of a %d-page document", page, expectedPages) + .contains("Page " + page + " of " + expectedPages); + } + } + } + + @ParameterizedTest(name = "a repeated header row does not disturb the count over {0} rows") + @CsvSource({ + "12, 2", + "40, 6" + }) + void repeatedHeaderRowDoesNotDisturbTheCount(int bodyRowCount, int expectedPages) throws Exception { + List> rows = new ArrayList<>(bodyRowCount + 1); + rows.add(List.of(DocumentTableCell.text("Description"), DocumentTableCell.text("Amount"))); + for (int index = 1; index <= bodyRowCount; index++) { + rows.add(List.of( + DocumentTableCell.text("Line item " + index), + DocumentTableCell.text("$" + (index * 10)))); + } + + byte[] pdfBytes; + try (DocumentSession session = GraphCompose.document() + .pageSize(400, 220) + .margin(DocumentInsets.of(20)) + .create()) { + session.chrome().footer(DocumentHeaderFooter.builder() + .zone(DocumentHeaderFooterZone.FOOTER) + .centerText("Page {page} of {pages}") + .build()); + session.dsl() + .pageFlow() + .name("InvoiceFlow") + .addTable(table -> { + table.name("Invoice") + .columns(DocumentTableColumn.fixed(200), DocumentTableColumn.fixed(140)); + rows.forEach(table::rowCells); + table.repeatHeader(); + }) + .build(); + pdfBytes = session.toPdfBytes(); + } + + try (PDDocument document = Loader.loadPDF(pdfBytes)) { + assertThat(document.getNumberOfPages()).isEqualTo(expectedPages); + + PDFTextStripper stripper = new PDFTextStripper(); + for (int page = 1; page <= expectedPages; page++) { + stripper.setStartPage(page); + stripper.setEndPage(page); + String text = stripper.getText(document); + assertThat(text) + .describedAs("page %d of a %d-page document with a repeated header", page, expectedPages) + .contains("Page " + page + " of " + expectedPages); + assertThat(text) + .describedAs("the repeated header row must reach page %d", page) + .contains("Description"); + } + } + } +} diff --git a/qa/src/test/java/com/demcha/compose/document/layout/RowInFixedSlotLayoutTest.java b/qa/src/test/java/com/demcha/compose/document/layout/RowInFixedSlotLayoutTest.java new file mode 100644 index 000000000..40d8969b2 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/layout/RowInFixedSlotLayoutTest.java @@ -0,0 +1,174 @@ +package com.demcha.compose.document.layout; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.layout.payloads.ParagraphFragmentPayload; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.LayerStackNode; +import com.demcha.compose.document.node.ParagraphNode; +import com.demcha.compose.document.node.RowNode; +import com.demcha.compose.document.node.TableNode; +import com.demcha.compose.document.node.TextAlign; +import com.demcha.compose.document.style.DocumentCornerRadius; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.document.table.DocumentTableCell; +import com.demcha.compose.document.table.DocumentTableColumn; +import com.demcha.compose.document.table.DocumentTableStyle; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.within; + +/** + * A row nested in a fixed rectangle keeps its horizontal band. + * + *

Fixed rectangles — a {@link LayerStackNode} layer, a composed + * {@code TableNode} cell — are laid out by a walk that seats children with a + * vertical cursor. That is right for a section or a container and wrong for a + * row, whose children share one band and split its width. When the walk had no + * horizontal branch, a nested row stacked its children downwards; because the + * band was measured as one row tall, everything after the first child also + * spilled out of the rectangle. Nothing threw and nothing was missing from the + * page, so only geometry catches it — these cases pin the nested row's + * fragments against the same row laid out at page level.

+ */ +class RowInFixedSlotLayoutTest { + + private static final double TOLERANCE = 0.01; + + private static ParagraphNode paragraph(String name, String text) { + return new ParagraphNode(name, text, DocumentTextStyle.DEFAULT, TextAlign.LEFT, 0.0, + DocumentInsets.zero(), DocumentInsets.zero()); + } + + private static RowNode twoColumnRow(String name) { + return new RowNode(name, + List.of(paragraph("Left", "LEFT-TEXT"), paragraph("Right", "RIGHT-TEXT")), + List.of(1.0, 1.0), 8.0, + DocumentInsets.zero(), DocumentInsets.zero(), + null, null, DocumentCornerRadius.ZERO); + } + + private static List paragraphFragments(DocumentNode root) throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(420, 300) + .margin(DocumentInsets.of(20)) + .create()) { + session.add(root); + return session.layoutGraph().fragments().stream() + .filter(fragment -> fragment.payload() instanceof ParagraphFragmentPayload) + .toList(); + } + } + + @Test + void rowInsideAStackLayerSeatsChildrenExactlyLikeAPageLevelRow() throws Exception { + List pageLevel = paragraphFragments(twoColumnRow("PageRow")); + List inLayer = paragraphFragments(new LayerStackNode( + "Stack", + List.of(new LayerStackNode.Layer(twoColumnRow("LayerRow"))), + DocumentInsets.zero(), DocumentInsets.zero())); + + assertThat(pageLevel).hasSize(2); + assertThat(inLayer).hasSize(2); + for (int index = 0; index < 2; index++) { + assertThat(inLayer.get(index).x()) + .describedAs("child %d must sit at the same x as at page level", index) + .isCloseTo(pageLevel.get(index).x(), within(TOLERANCE)); + assertThat(inLayer.get(index).y()) + .describedAs("child %d must sit at the same y as at page level", index) + .isCloseTo(pageLevel.get(index).y(), within(TOLERANCE)); + } + } + + @Test + void rowInsideAComposedTableCellKeepsItsChildrenOnOneBand() throws Exception { + TableNode table = new TableNode( + "RowCellTable", + List.of(DocumentTableColumn.fixed(260), DocumentTableColumn.fixed(80)), + List.of(List.of( + DocumentTableCell.node(twoColumnRow("CellRow")), + DocumentTableCell.text("Neighbour"))), + DocumentTableStyle.empty(), + 340.0, + DocumentInsets.zero(), + DocumentInsets.zero()); + + List fragments = paragraphFragments(table); + + assertThat(fragments).hasSize(2); + PlacedFragment left = fragments.get(0); + PlacedFragment right = fragments.get(1); + assertThat(right.y()) + .describedAs("a row in a cell shares one band, so both children sit at the same y") + .isCloseTo(left.y(), within(TOLERANCE)); + assertThat(right.x()) + .describedAs("a row in a cell splits the cell width, so the second child sits to the right") + .isGreaterThan(left.x()); + } + + @Test + void aRowNestedInsideAnotherRowIsRejectedInsideAFixedRectangleToo() { + RowNode inner = new RowNode("Inner", + List.of(paragraph("InnerLeft", "I-A"), paragraph("InnerRight", "I-B")), + List.of(1.0, 1.0), 4.0, + DocumentInsets.zero(), DocumentInsets.zero(), + null, null, DocumentCornerRadius.ZERO); + RowNode outer = new RowNode("Outer", + List.of(inner, paragraph("OuterRight", "O-B")), + List.of(1.0, 1.0), 4.0, + DocumentInsets.zero(), DocumentInsets.zero(), + null, null, DocumentCornerRadius.ZERO); + + assertThatThrownBy(() -> paragraphFragments(new LayerStackNode( + "Stack", + List.of(new LayerStackNode.Layer(outer)), + DocumentInsets.zero(), DocumentInsets.zero()))) + .describedAs("a fixed-rectangle row must reject a nested horizontal row " + + "with the same diagnostic the page-level row band gives") + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("nested horizontal row"); + } + + @Test + void aRowCellDoesNotSpillBelowItsTableRow() throws Exception { + TableNode table = new TableNode( + "RowCellBoundsTable", + List.of(DocumentTableColumn.fixed(260), DocumentTableColumn.fixed(80)), + List.of(List.of( + DocumentTableCell.node(twoColumnRow("CellRow")), + DocumentTableCell.text("Neighbour"))), + DocumentTableStyle.empty(), + 340.0, + DocumentInsets.zero(), + DocumentInsets.zero()); + + try (DocumentSession session = GraphCompose.document() + .pageSize(420, 300) + .margin(DocumentInsets.of(20)) + .create()) { + session.add(table); + + LayoutGraph graph = session.layoutGraph(); + PlacedNode tableNode = graph.nodes().stream() + .filter(node -> "RowCellBoundsTable".equals(node.semanticName())) + .findFirst() + .orElseThrow(); + double tableBottom = tableNode.placementY(); + + List fragments = graph.fragments().stream() + .filter(fragment -> fragment.payload() instanceof ParagraphFragmentPayload) + .toList(); + assertThat(fragments).hasSize(2); + for (PlacedFragment fragment : fragments) { + assertThat(fragment.y()) + .describedAs("a row child must stay inside the table it was composed into") + .isGreaterThanOrEqualTo(tableBottom - TOLERANCE); + } + } + } +} diff --git a/qa/src/test/java/com/demcha/compose/document/table/DocumentTableCellCopyTest.java b/qa/src/test/java/com/demcha/compose/document/table/DocumentTableCellCopyTest.java new file mode 100644 index 000000000..31ecb091a --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/table/DocumentTableCellCopyTest.java @@ -0,0 +1,166 @@ +package com.demcha.compose.document.table; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.ParagraphNode; +import com.demcha.compose.document.node.SectionNode; +import com.demcha.compose.document.node.TableNode; +import com.demcha.compose.document.node.TextAlign; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentTextStyle; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@link DocumentTableCell} copy factories must carry every component across. + * + *

{@code withStyle} / {@code colSpan} / {@code rowSpan} each rebuild the + * record rather than mutating it, so a component dropped from one of those + * constructor calls would not fail to compile — the cell would just quietly + * lose its composed child, its span, or both, and the table would render a + * blank or mis-sized cell. These cases pin all five components through each + * copy, and then prove through the rendered page that a styled composed cell + * still draws the child it was built with.

+ */ +class DocumentTableCellCopyTest { + + private static ParagraphNode paragraph(String name, String text) { + return new ParagraphNode(name, text, DocumentTextStyle.DEFAULT, TextAlign.LEFT, 0.0, + DocumentInsets.zero(), DocumentInsets.zero()); + } + + private static DocumentTableStyle tintedStyle() { + return DocumentTableStyle.builder() + .fillColor(DocumentColor.rgb(238, 242, 250)) + .padding(DocumentInsets.of(6)) + .build(); + } + + static Stream composedContent() { + return Stream.of( + Arguments.of("ParagraphNode", paragraph("Leaf", "STYLED-PARAGRAPH"), "STYLED-PARAGRAPH"), + Arguments.of("SectionNode", + new SectionNode("Stacked", + List.of(paragraph("S1", "STYLED-SECTION-ONE"), + paragraph("S2", "STYLED-SECTION-TWO")), + 2.0, DocumentInsets.zero(), DocumentInsets.zero(), null, null), + "STYLED-SECTION-ONE")); + } + + @ParameterizedTest(name = "withStyle keeps {0} content and spans") + @MethodSource("composedContent") + void withStyleKeepsComposedContentAndSpans(String label, DocumentNode content, String ignored) { + DocumentTableCell base = DocumentTableCell.node(content).colSpan(2).rowSpan(3); + DocumentTableStyle style = tintedStyle(); + + DocumentTableCell styled = base.withStyle(style); + + assertThat(styled.content()) + .describedAs("withStyle must carry the composed %s across the copy", label) + .isSameAs(content); + assertThat(styled.hasComposedContent()).isTrue(); + assertThat(styled.colSpan()).isEqualTo(2); + assertThat(styled.rowSpan()).isEqualTo(3); + assertThat(styled.style()).isSameAs(style); + assertThat(styled.lines()).isEqualTo(base.lines()); + } + + @ParameterizedTest(name = "colSpan/rowSpan keep {0} content and style") + @MethodSource("composedContent") + void spanCopiesKeepComposedContentAndStyle(String label, DocumentNode content, String ignored) { + DocumentTableStyle style = tintedStyle(); + DocumentTableCell base = DocumentTableCell.node(content).withStyle(style); + + DocumentTableCell wider = base.colSpan(2); + DocumentTableCell taller = base.rowSpan(4); + + assertThat(wider.content()) + .describedAs("colSpan must carry the composed %s across the copy", label) + .isSameAs(content); + assertThat(wider.style()).isSameAs(style); + assertThat(wider.colSpan()).isEqualTo(2); + assertThat(wider.rowSpan()).isEqualTo(1); + + assertThat(taller.content()) + .describedAs("rowSpan must carry the composed %s across the copy", label) + .isSameAs(content); + assertThat(taller.style()).isSameAs(style); + assertThat(taller.colSpan()).isEqualTo(1); + assertThat(taller.rowSpan()).isEqualTo(4); + } + + @ParameterizedTest(name = "a styled {0} cell still renders its child") + @MethodSource("composedContent") + void styledComposedCellStillRendersItsChild(String label, + DocumentNode content, + String expectedText) throws Exception { + TableNode table = new TableNode( + "StyledComposed", + List.of(DocumentTableColumn.fixed(200), DocumentTableColumn.fixed(140)), + List.of(List.of( + DocumentTableCell.node(content).withStyle(tintedStyle()), + DocumentTableCell.text("Neighbour"))), + DocumentTableStyle.empty(), + 340.0, + DocumentInsets.zero(), + DocumentInsets.zero()); + + byte[] pdfBytes; + try (DocumentSession session = GraphCompose.document() + .pageSize(420, 300) + .margin(DocumentInsets.of(20)) + .create()) { + session.add(table); + pdfBytes = session.toPdfBytes(); + } + + try (PDDocument document = Loader.loadPDF(pdfBytes)) { + String extracted = new PDFTextStripper().getText(document); + assertThat(extracted) + .describedAs("a styled composed %s cell must still render its child", label) + .contains(expectedText); + assertThat(extracted).contains("Neighbour"); + } + } + + @Test + void styleOverrideDoesNotChangeWhichTextTheCellRenders() throws Exception { + assertThat(renderedText(null)).isEqualTo(renderedText(tintedStyle())); + } + + private static String renderedText(DocumentTableStyle style) throws Exception { + DocumentTableCell cell = DocumentTableCell.node(paragraph("Body", "SAME-TEXT-EITHER-WAY")); + TableNode table = new TableNode( + "StyleParity", + List.of(DocumentTableColumn.fixed(200)), + List.of(List.of(style == null ? cell : cell.withStyle(style))), + DocumentTableStyle.empty(), + 200.0, + DocumentInsets.zero(), + DocumentInsets.zero()); + + byte[] pdfBytes; + try (DocumentSession session = GraphCompose.document() + .pageSize(420, 300) + .margin(DocumentInsets.of(20)) + .create()) { + session.add(table); + pdfBytes = session.toPdfBytes(); + } + try (PDDocument document = Loader.loadPDF(pdfBytes)) { + return new PDFTextStripper().getText(document); + } + } +} diff --git a/qa/src/test/java/com/demcha/compose/document/table/TableCellComposedNodeTypeTest.java b/qa/src/test/java/com/demcha/compose/document/table/TableCellComposedNodeTypeTest.java new file mode 100644 index 000000000..04853ad84 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/table/TableCellComposedNodeTypeTest.java @@ -0,0 +1,210 @@ +package com.demcha.compose.document.table; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.layout.PlacedFragment; +import com.demcha.compose.document.node.AlignNode; +import com.demcha.compose.document.node.ContainerNode; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.node.LayerStackNode; +import com.demcha.compose.document.node.ListMarker; +import com.demcha.compose.document.node.ListNode; +import com.demcha.compose.document.node.ParagraphNode; +import com.demcha.compose.document.node.HorizontalAlign; +import com.demcha.compose.document.node.RowNode; +import com.demcha.compose.document.node.SectionNode; +import com.demcha.compose.document.node.ShapeContainerNode; +import com.demcha.compose.document.node.TableNode; +import com.demcha.compose.document.node.TextAlign; +import com.demcha.compose.document.style.ClipPolicy; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentCornerRadius; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentStroke; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.compose.document.style.ShapeOutline; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A composed table cell renders composite content, not just leaf content. + * + *

The matrix covers every composite node kind a cell can hold — + * section, container, row, layer stack, align — plus the leaf and nested-table + * shapes that already worked, as the control group. A composite owns its + * children through {@code NodeDefinition.children(...)} rather than emitting + * them from {@code emitFragments}, so a composed cell has to walk the whole + * sub-tree; dispatching to the child's own {@code emitFragments} alone yields + * the composite's decoration and silently drops everything inside it. + * + *

The cell reserves the child's measured height either way, so a dropped + * sub-tree is invisible in the page geometry: it renders as a correctly-sized + * blank hole. These cases assert on the rendered PDF text so a regression + * cannot pass by producing well-shaped emptiness.

+ */ +class TableCellComposedNodeTypeTest { + + private static ParagraphNode paragraph(String name, String text) { + return new ParagraphNode(name, text, DocumentTextStyle.DEFAULT, TextAlign.LEFT, 0.0, + DocumentInsets.zero(), DocumentInsets.zero()); + } + + static Stream composedCellContent() { + return Stream.of( + Arguments.of("ParagraphNode", + paragraph("Leaf", "MARK-PARAGRAPH"), + List.of("MARK-PARAGRAPH")), + Arguments.of("ListNode", + new ListNode("Bullets", List.of("MARK-LIST-ONE", "MARK-LIST-TWO"), + ListMarker.bullet(), DocumentTextStyle.DEFAULT, TextAlign.LEFT, + 0.0, 2.0, null, true, DocumentInsets.zero(), DocumentInsets.zero()), + List.of("MARK-LIST-ONE", "MARK-LIST-TWO")), + Arguments.of("SectionNode", + new SectionNode("Stacked", + List.of(paragraph("S1", "MARK-SECTION-ONE"), + paragraph("S2", "MARK-SECTION-TWO")), + 2.0, DocumentInsets.zero(), DocumentInsets.zero(), null, null), + List.of("MARK-SECTION-ONE", "MARK-SECTION-TWO")), + Arguments.of("ContainerNode", + new ContainerNode("Boxed", + List.of(paragraph("C1", "MARK-CONTAINER")), + 2.0, DocumentInsets.zero(), DocumentInsets.zero(), null, null), + List.of("MARK-CONTAINER")), + // A row splits the cell width between its children, so its + // markers are short enough to survive in half a column. + Arguments.of("RowNode", + new RowNode("Side", + List.of(paragraph("R1", "ROW-A"), + paragraph("R2", "ROW-B")), + List.of(1.0, 1.0), 4.0, + DocumentInsets.zero(), DocumentInsets.zero(), null, null, + DocumentCornerRadius.ZERO), + List.of("ROW-A", "ROW-B")), + Arguments.of("LayerStackNode", + new LayerStackNode("Stack", + List.of(new LayerStackNode.Layer(paragraph("L1", "MARK-LAYERSTACK"))), + DocumentInsets.zero(), DocumentInsets.zero()), + List.of("MARK-LAYERSTACK")), + Arguments.of("AlignNode", + new AlignNode(paragraph("A1", "MARK-ALIGN"), HorizontalAlign.CENTER), + List.of("MARK-ALIGN")), + Arguments.of("nested TableNode", + new TableNode("Inner", + List.of(DocumentTableColumn.fixed(170)), + List.of(List.of(DocumentTableCell.text("MARK-NESTED-TABLE"))), + DocumentTableStyle.empty(), 170.0, + DocumentInsets.zero(), DocumentInsets.zero()), + List.of("MARK-NESTED-TABLE"))); + } + + @ParameterizedTest(name = "{0} renders inside a composed table cell") + @MethodSource("composedCellContent") + void composedCellRendersCompositeAndLeafContent(String label, + DocumentNode content, + List expectedText) throws Exception { + // The composed column is wide enough that a row child still gets a + // legible half-slot: the assertions below read the rendered text, and a + // marker broken across a wrap would fail for the wrong reason. + TableNode table = new TableNode( + "ComposedMatrix", + List.of(DocumentTableColumn.fixed(260), DocumentTableColumn.fixed(80)), + List.of(List.of(DocumentTableCell.node(content), DocumentTableCell.text("Neighbour"))), + DocumentTableStyle.empty(), + 340.0, + DocumentInsets.zero(), + DocumentInsets.zero()); + + byte[] pdfBytes; + List fragments; + try (DocumentSession session = GraphCompose.document() + .pageSize(420, 300) + .margin(DocumentInsets.of(20)) + .create()) { + session.add(table); + + fragments = session.layoutGraph().fragments(); + pdfBytes = session.toPdfBytes(); + } + + // The outer table is a single row, so it contributes exactly one + // TableRowFragmentPayload of its own. Anything beyond that came from + // the composed child's sub-tree. + assertThat(fragments) + .describedAs("%s composed into a cell must contribute fragments beyond the " + + "single row fragment the outer table emits for itself", label) + .hasSizeGreaterThan(1); + + try (PDDocument document = Loader.loadPDF(pdfBytes)) { + String extracted = new PDFTextStripper().getText(document); + assertThat(extracted) + .describedAs("%s composed into a cell must reach the rendered page", label) + .contains(expectedText); + assertThat(extracted).contains("Neighbour"); + } + } + + /** + * A clipping composite writes graphics state that has to be handed back: + * {@code ShapeClipBegin} … children … {@code ShapeClipEnd}. If the cell + * walk dropped the closing marker, the clip would leak onto whatever the + * backend paints next — a mispaint several fragments later, with nothing + * missing from the page and no exception anywhere. Pinned against the same + * container rendered at page level. + */ + @Test + void aClippingCompositeInACellKeepsItsClipPairBalanced() throws Exception { + List inCell = payloadKinds(new TableNode( + "ClipCellTable", + List.of(DocumentTableColumn.fixed(200), DocumentTableColumn.fixed(140)), + List.of(List.of( + DocumentTableCell.node(clippedContainer("CellClip")), + DocumentTableCell.text("Neighbour"))), + DocumentTableStyle.empty(), + 340.0, + DocumentInsets.zero(), + DocumentInsets.zero())); + List atPageLevel = payloadKinds(clippedContainer("PageClip")); + + assertThat(inCell) + .describedAs("a clipping container in a cell must emit the same ordered " + + "fragment sequence it emits at page level, plus the table's own row") + .containsSubsequence(atPageLevel); + assertThat(inCell).filteredOn("ShapeClipBeginPayload"::equals).hasSize(1); + assertThat(inCell).filteredOn("ShapeClipEndPayload"::equals).hasSize(1); + assertThat(inCell.indexOf("ShapeClipEndPayload")) + .describedAs("the clip must close after the child it wraps") + .isGreaterThan(inCell.indexOf("ParagraphFragmentPayload")); + } + + private static ShapeContainerNode clippedContainer(String name) { + return new ShapeContainerNode(name, + new ShapeOutline.RoundedRectangle(140, 40, 8), + List.of(new LayerStackNode.Layer(paragraph("Clipped", "MARK-CLIPPED"))), + ClipPolicy.CLIP_PATH, + DocumentColor.rgb(240, 244, 252), + DocumentStroke.of(DocumentColor.rgb(20, 60, 75), 1.0), + DocumentInsets.zero(), DocumentInsets.zero()); + } + + private static List payloadKinds(DocumentNode root) throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(420, 300) + .margin(DocumentInsets.of(20)) + .create()) { + session.add(root); + return session.layoutGraph().fragments().stream() + .map(fragment -> fragment.payload().getClass().getSimpleName()) + .toList(); + } + } +}