From cac6148da55b4356b06ddc54f7d2b0f23be7e747 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 25 Aug 2026 14:29:17 +0100 Subject: [PATCH 1/2] fix(engine): lay out composite children inside a composed table cell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DocumentTableCell.node(...) holding a SectionNode, ContainerNode, RowNode, LayerStackNode or AlignNode 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. FragmentContext.emitChildFragments now lays a composite child's whole sub-tree out inside the placement, through the compiler's fixed-slot walk, so the sub-tree gets the same column / row / stack layout it gets at document level. Leaf children and nested tables keep their existing single-dispatch path and are unchanged. That walk turned out to have no horizontal branch at all: a RowNode nested in a fixed rectangle — a LayerStack layer since 1.6.2, and now a table cell — stacked its children downwards and, because the band is measured one row tall, spilled every child after the first out of the rectangle. It now resolves slot widths through the same RowSlots path the page-level row band uses, and still rejects a nested horizontal row with the page-level diagnostic. Docs: the advanced-tables recipe demonstrated a multi-line cell with a "\n" inside text(...), which renders as one line. The recipe and the DocumentTableCell Javadoc now name the three cell shapes — text(...) for one line, lines(...) for several, node(...) for a node — and the two examples that showed the misleading form use lines(...). Tests: ./mvnw clean verify on the eight-module reactor — BUILD SUCCESS, qa 767 tests, no baselines moved. Four new qa classes, 23 cases: the composed-content matrix over every composite kind plus leaf and nested-table controls; row geometry in a stack layer and in a cell, each red without the horizontal branch; DocumentTableCell copy-factory preservation of content, style and spans; and Page {page} of {pages} across 1 / 2 / 5 pages, with and without a repeated header row. --- CHANGELOG.md | 37 ++++ .../examples/composed-table-cell-showcase.pdf | Bin 3122 -> 3535 bytes assets/readme/examples/table-advanced.pdf | Bin 5809 -> 5829 bytes .../layout/DocumentLayoutPassContext.java | 60 ++++++ .../document/layout/FragmentContext.java | 17 +- .../document/layout/LayoutCompiler.java | 202 ++++++++++++++++-- .../document/table/DocumentTableCell.java | 23 +- docs/recipes/tables.md | 44 +++- examples/README.md | 2 +- .../tables/ComposedTableCellExample.java | 62 ++++++ .../features/tables/TableAdvancedExample.java | 4 +- .../api/FooterPageNumberingOverflowTest.java | 138 ++++++++++++ .../layout/RowInFixedSlotLayoutTest.java | 150 +++++++++++++ .../table/DocumentTableCellCopyTest.java | 166 ++++++++++++++ .../table/TableCellComposedNodeTypeTest.java | 149 +++++++++++++ 15 files changed, 1029 insertions(+), 25 deletions(-) create mode 100644 qa/src/test/java/com/demcha/compose/document/api/FooterPageNumberingOverflowTest.java create mode 100644 qa/src/test/java/com/demcha/compose/document/layout/RowInFixedSlotLayoutTest.java create mode 100644 qa/src/test/java/com/demcha/compose/document/table/DocumentTableCellCopyTest.java create mode 100644 qa/src/test/java/com/demcha/compose/document/table/TableCellComposedNodeTypeTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b33d552e..dde12c7b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,32 @@ 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, and a nested *horizontal* + row is still rejected with the same diagnostic as at page level. 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. + - **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 +95,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 a51388741b45c7e448422be9638abce08634462a..e9e827a80a50647c529eff044630ac2f68ff67aa 100644 GIT binary patch delta 2913 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 diff --git a/assets/readme/examples/table-advanced.pdf b/assets/readme/examples/table-advanced.pdf index 3e426d5df86f9cf79d00230204dc58018c68a822..6f983e3ce176878c1c072b7674d1d095598f3ea6 100644 GIT binary patch delta 2975 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 O8%&K|QW9;V$Nn#~6PPIg delta 2955 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 voNVBpTV_c<{2nN 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..560fcbb25 --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/layout/RowInFixedSlotLayoutTest.java @@ -0,0 +1,150 @@ +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.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 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..e00e8a91f --- /dev/null +++ b/qa/src/test/java/com/demcha/compose/document/table/TableCellComposedNodeTypeTest.java @@ -0,0 +1,149 @@ +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.RowNode; +import com.demcha.compose.document.node.HorizontalAlign; +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.DocumentCornerRadius; +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.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"); + } + } +} From d3ce7239c516a3636f8fbb20cbff748f532c1902 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Tue, 25 Aug 2026 15:58:23 +0100 Subject: [PATCH 2/2] test(engine): pin the nested-row rejection and the clip pair in a composed cell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two behaviours the first pass changed or newly relied on, neither of which anything guarded. A row nested directly inside another row in a fixed rectangle now raises the page-level "cannot contain a nested horizontal row" diagnostic instead of laying out. What it laid out before was not usable — 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 — but the change from a render to an exception is worth stating, so the CHANGELOG entry says it and a negative case pins it. A clipping composite (ShapeContainerNode with CLIP_PATH) writes graphics state that has to be handed back. Inside a cell it emits Shape / ClipBegin / child / ClipEnd, the same ordered sequence it emits at page level, and the new case asserts the cell sequence against the page-level one rather than a hardcoded list. A dropped ClipEnd would leak the clip onto later fragments with nothing missing from the page and no exception raised. Tests: ./mvnw clean verify on the eight-module reactor — BUILD SUCCESS, qa 769 tests (+2). --- CHANGELOG.md | 16 +++-- .../layout/RowInFixedSlotLayoutTest.java | 24 +++++++ .../table/TableCellComposedNodeTypeTest.java | 63 ++++++++++++++++++- 3 files changed, 98 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dde12c7b0..9b69abcd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,10 +47,18 @@ follow semantic versioning; release dates are ISO 8601. 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, and a nested *horizontal* - row is still rejected with the same diagnostic as at page level. 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. + 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 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 index 560fcbb25..40d8969b2 100644 --- a/qa/src/test/java/com/demcha/compose/document/layout/RowInFixedSlotLayoutTest.java +++ b/qa/src/test/java/com/demcha/compose/document/layout/RowInFixedSlotLayoutTest.java @@ -20,6 +20,7 @@ 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; /** @@ -110,6 +111,29 @@ void rowInsideAComposedTableCellKeepsItsChildrenOnOneBand() throws Exception { .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( 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 index e00e8a91f..04853ad84 100644 --- a/qa/src/test/java/com/demcha/compose/document/table/TableCellComposedNodeTypeTest.java +++ b/qa/src/test/java/com/demcha/compose/document/table/TableCellComposedNodeTypeTest.java @@ -10,17 +10,23 @@ 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.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; @@ -146,4 +152,59 @@ void composedCellRendersCompositeAndLeafContent(String label, 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(); + } + } }