diff --git a/CHANGELOG.md b/CHANGELOG.md index a3dc714b7..b9ab9f314 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,15 @@ PDF `GoTo` actions. External links are unchanged. ### Public API +- **`RowBuilder.columns(...)` + `DocumentRowColumn`** (`@since 1.9.0`). Size each row + column explicitly: `DocumentRowColumn.fixed(pt)`, `auto()` (intrinsic content + width), or `weight(w)` (a share of the space left after the fixed and intrinsic + columns). Mix them freely — `columns(auto(), weight(1), auto())` with a + `line().fill()` in the middle is a dot-leader table-of-contents row, with the + label and page number sized to their content. `weights(...)` stays as sugar for + the even / weighted split (and a weight-only column list resolves identically), + so existing rows are byte-identical. + - **`LineBuilder.fill()`** (`@since 1.9.0`). A line stretches to the width available where it is placed — its column inside a row, or the content width at flow level — instead of its authored fixed width. Paired with a dotted stroke diff --git a/assets/readme/examples/row-columns.pdf b/assets/readme/examples/row-columns.pdf new file mode 100644 index 000000000..cdbbb871e Binary files /dev/null and b/assets/readme/examples/row-columns.pdf differ diff --git a/examples/README.md b/examples/README.md index 8772780c6..6a955a144 100644 --- a/examples/README.md +++ b/examples/README.md @@ -76,6 +76,7 @@ are with the canonical DSL, then jump to its detailed section below. | [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) | | [Block alignment](#block-alignment) | `addAligned(align, node)` / `addSvgIcon(icon, w, align)` — seat any fixed-size node left / centre / right across the content width | [PDF](../assets/readme/examples/block-align.pdf) · [Source](src/main/java/com/demcha/examples/features/layout/BlockAlignExample.java) | | [Content bleed](#content-bleed) | `band.bleedToEdge(TOP, LEFT, RIGHT)` / `bleed(DocumentBleed.of(...))` — a section's fill reaches the trimmed page edge while its children stay in the content margin | [PDF](../assets/readme/examples/content-bleed.pdf) · [Source](src/main/java/com/demcha/examples/features/layout/BleedExample.java) | +| [Row columns & TOC](#row-columns--toc) | `row.columns(auto(), weight(1), auto())` — size columns by content / fixed points / weight; with `line().fill()` it builds a dot-leader table of contents | [PDF](../assets/readme/examples/row-columns.pdf) · [Source](src/main/java/com/demcha/examples/features/layout/RowColumnsExample.java) | ### 📋 Templates recommended @@ -473,6 +474,24 @@ page.addSection(band -> band [📄 View PDF](../assets/readme/examples/content-bleed.pdf) · [📜 Full source](src/main/java/com/demcha/examples/features/layout/BleedExample.java) +### Row columns & TOC + +`RowBuilder.columns(...)` sizes each column as fixed points, intrinsic content +width (`auto()`), or a `weight()` share of the remainder — `weights(...)` stays +as sugar for the even / weighted split. Combined with `line().fill()` it builds a +table-of-contents row without measuring the gap: the label and page number size +to their content while the dotted leader fills between them. + +```java +flow.addRow(r -> r.columns(auto(), weight(1), auto()) + .addParagraph(label) + .addLine(l -> l.fill().dashed(0.1, 4).lineCap(DocumentLineCap.ROUND)) // leader fills the gap + .addParagraph(pageNumber)); +``` + +[📄 View PDF](../assets/readme/examples/row-columns.pdf) · +[📜 Full source](src/main/java/com/demcha/examples/features/layout/RowColumnsExample.java) + ### Advanced tables `DocumentTableCell.rowSpan(int)` mirrors `colSpan(int)`. diff --git a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java index 301f62812..5aca5a38c 100644 --- a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java +++ b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java @@ -8,6 +8,7 @@ import com.demcha.examples.features.chrome.PageNumberingExample; import com.demcha.examples.features.chrome.PdfChromeExample; import com.demcha.examples.features.layout.BleedExample; +import com.demcha.examples.features.layout.RowColumnsExample; import com.demcha.examples.features.layout.BlockAlignExample; import com.demcha.examples.features.lists.NestedListExample; import com.demcha.examples.features.shapes.LineCapExample; @@ -153,6 +154,7 @@ public static void main(String[] args) throws Exception { System.out.println("Generated: " + SvgIconGalleryExample.generate()); System.out.println("Generated: " + BlockAlignExample.generate()); System.out.println("Generated: " + BleedExample.generate()); + System.out.println("Generated: " + RowColumnsExample.generate()); System.out.println("Generated: " + TransformsExample.generate()); System.out.println("Generated: " + TableAdvancedExample.generate()); diff --git a/examples/src/main/java/com/demcha/examples/features/layout/RowColumnsExample.java b/examples/src/main/java/com/demcha/examples/features/layout/RowColumnsExample.java new file mode 100644 index 000000000..678ce4fd7 --- /dev/null +++ b/examples/src/main/java/com/demcha/examples/features/layout/RowColumnsExample.java @@ -0,0 +1,100 @@ +package com.demcha.examples.features.layout; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.dsl.PageFlowBuilder; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentLineCap; +import com.demcha.compose.document.style.DocumentStroke; +import com.demcha.compose.document.style.DocumentTextStyle; +import com.demcha.examples.support.ExampleOutputPaths; + +import java.nio.file.Path; + +import static com.demcha.compose.document.style.DocumentRowColumn.auto; +import static com.demcha.compose.document.style.DocumentRowColumn.weight; + +/** + * Runnable showcase for v1.9 row columns: {@code RowBuilder.columns(...)} sizes + * each column as fixed points, intrinsic ({@code auto()}) content width, or a + * {@code weight()} share of the remainder. Combined with {@code line().fill()} + * it builds a table-of-contents row without measuring the gap — the label and + * page number size to their content while the dotted leader fills between them. + * + *
{@code
+ * flow.addRow(r -> r.columns(auto(), weight(1), auto())
+ *     .addParagraph(label)
+ *     .addLine(l -> l.fill().dashed(0.1, 4).lineCap(ROUND))   // leader fills the gap
+ *     .addParagraph(pageNumber));
+ * }
+ * + * @author Artem Demchyshyn + */ +public final class RowColumnsExample { + + private static final DocumentColor INK = DocumentColor.rgb(24, 28, 38); + private static final DocumentColor MUTED = DocumentColor.rgb(120, 126, 135); + + private static final String[][] ENTRIES = { + {"Introduction", "1"}, + {"Getting started", "4"}, + {"A longer chapter title that runs on", "12"}, + {"Appendix", "28"}, + }; + + private RowColumnsExample() { + } + + /** + * Renders a table-of-contents block built from {@code columns(auto(), + * weight(1), auto())} rows with dotted-leader fill lines. + * + * @return path to the generated PDF + * @throws Exception if rendering or file IO fails + */ + public static Path generate() throws Exception { + Path pdfFile = ExampleOutputPaths.prepare("features/layout", "row-columns.pdf"); + + DocumentTextStyle entry = DocumentTextStyle.DEFAULT.withSize(11).withColor(INK); + DocumentStroke dots = DocumentStroke.of(MUTED, 1.3); + + try (DocumentSession document = GraphCompose.document(pdfFile) + .pageSize(380, 260) + .margin(DocumentInsets.of(36)) + .create()) { + document.pageFlow(page -> { + page.addParagraph(p -> p.text("Table of contents") + .textStyle(DocumentTextStyle.DEFAULT.withSize(18))); + page.addParagraph(p -> p.text("columns(auto(), weight(1), auto()) + line().fill()") + .textStyle(DocumentTextStyle.DEFAULT.withSize(9).withColor(MUTED)) + .padding(DocumentInsets.bottom(8))); + + for (String[] item : ENTRIES) { + tocRow(page, entry, dots, item[0], item[1]); + } + }); + + document.buildPdf(); + } + + return pdfFile; + } + + private static void tocRow(PageFlowBuilder page, + DocumentTextStyle entry, + DocumentStroke dots, + String label, + String pageNumber) { + page.addRow(r -> r.gap(6).columns(auto(), weight(1), auto()) + .padding(DocumentInsets.symmetric(0, 5)) + .addParagraph(p -> p.text(label).textStyle(entry)) + .addLine(l -> l.fill().height(11).stroke(dots) + .dashed(0.1, 4).lineCap(DocumentLineCap.ROUND)) + .addParagraph(p -> p.text(pageNumber).textStyle(entry))); + } + + public static void main(String[] args) throws Exception { + System.out.println("Generated: " + generate()); + } +} diff --git a/src/main/java/com/demcha/compose/document/dsl/RowBuilder.java b/src/main/java/com/demcha/compose/document/dsl/RowBuilder.java index 6c590d186..50fedf98d 100644 --- a/src/main/java/com/demcha/compose/document/dsl/RowBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/RowBuilder.java @@ -32,6 +32,7 @@ public final class RowBuilder { private final List children = new ArrayList<>(); private final List weights = new ArrayList<>(); + private final List columns = new ArrayList<>(); private String name = ""; private boolean weightsDirty; private double gap; @@ -186,6 +187,7 @@ public RowBuilder borders(DocumentBorders borders) { */ public RowBuilder weights(double... weights) { this.weights.clear(); + this.columns.clear(); if (weights != null) { for (double w : weights) { this.weights.add(w); @@ -202,10 +204,39 @@ public RowBuilder weights(double... weights) { */ public RowBuilder evenWeights() { this.weights.clear(); + this.columns.clear(); this.weightsDirty = true; return this; } + /** + * Sizes each column explicitly: a fixed point width, an automatic + * (content-sized) width via {@link DocumentRowColumn#auto()}, or a weight that + * shares the leftover space. Mix them freely — a dot-leader row is + * {@code columns(auto(), weight(1), auto())} with a {@code line().fill()} in + * the middle. The count must match the children at {@link #build()}. + * + *

Fixed and auto columns are left-packed: add a {@code weight(...)} column + * to absorb the remainder, otherwise trailing space is left empty. Mutually + * exclusive with {@link #weights(double...)}; calling one clears the other. A + * weight-only column list resolves identically to {@code weights(...)}.

+ * + * @param columns one width spec per row child + * @return this builder + * @since 1.9.0 + */ + public RowBuilder columns(DocumentRowColumn... columns) { + this.columns.clear(); + this.weights.clear(); + this.weightsDirty = false; + if (columns != null) { + for (DocumentRowColumn column : columns) { + this.columns.add(column); + } + } + return this; + } + /** * Adds a pre-built atomic node as the next row child. Validates the child * type immediately so authoring mistakes (e.g. dropping a row or a table @@ -409,7 +440,8 @@ public RowNode build() { fillColor, stroke, cornerRadius, - borders); + borders, + List.copyOf(columns)); } private void validate() { @@ -418,5 +450,10 @@ private void validate() { + " does not match children size " + children.size() + ". Pass " + children.size() + " weights or call evenWeights()."); } + if (!columns.isEmpty() && columns.size() != children.size()) { + throw new IllegalStateException("RowBuilder columns size " + columns.size() + + " does not match children size " + children.size() + + ". Pass " + children.size() + " columns."); + } } } diff --git a/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java b/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java index 20e326be8..69b7ab6ff 100644 --- a/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java +++ b/src/main/java/com/demcha/compose/document/layout/LayoutCompiler.java @@ -5,8 +5,10 @@ import com.demcha.compose.document.node.DocumentNode; import com.demcha.compose.document.node.LayerStackNode; import com.demcha.compose.document.node.PageBreakNode; +import com.demcha.compose.document.node.RowNode; import com.demcha.compose.document.style.DocumentBleed; import com.demcha.compose.document.style.DocumentEdge; +import com.demcha.compose.document.style.DocumentRowColumn; import com.demcha.compose.engine.components.style.Margin; import com.demcha.compose.engine.components.style.Padding; import org.slf4j.Logger; @@ -506,8 +508,16 @@ private void compileHorizontalRow(PreparedNode prepared, double rowInnerY = placementTopY - padding.top(); if (!children.isEmpty()) { - double[] slotWidths = distributeRowSlotWidths(children, layoutSpec.weights(), - layoutSpec.spacing(), childRegionWidth); + List columns = node instanceof RowNode row ? row.columns() : List.of(); + double[] slotWidths; + if (!columns.isEmpty()) { + double available = RowSlots.rowAvailableWidth(childRegionWidth, layoutSpec.spacing(), children.size()); + double[] intrinsic = RowSlots.intrinsicColumnWidths(children, columns, available, prepareContext); + slotWidths = RowSlots.distributeColumns(columns, intrinsic, layoutSpec.spacing(), childRegionWidth, semanticName); + } else { + slotWidths = distributeRowSlotWidths(children, layoutSpec.weights(), + layoutSpec.spacing(), childRegionWidth); + } double cursorX = placementX + padding.left(); for (int index = 0; index < children.size(); index++) { diff --git a/src/main/java/com/demcha/compose/document/layout/NodeDefinitionSupport.java b/src/main/java/com/demcha/compose/document/layout/NodeDefinitionSupport.java index 4f51392ad..bae2a4255 100644 --- a/src/main/java/com/demcha/compose/document/layout/NodeDefinitionSupport.java +++ b/src/main/java/com/demcha/compose/document/layout/NodeDefinitionSupport.java @@ -346,7 +346,10 @@ public static MeasureResult measureRow(RowNode node, double totalGap = n > 1 ? gap * (n - 1) : 0.0; double slotsTotal = Math.max(0.0, availableWidth - totalGap); double[] slotWidths = new double[n]; - if (node.weights().isEmpty()) { + if (!node.columns().isEmpty()) { + double[] intrinsic = RowSlots.intrinsicColumnWidths(node.children(), node.columns(), slotsTotal, ctx); + slotWidths = RowSlots.distributeColumns(node.columns(), intrinsic, gap, availableWidth, node.name()); + } else if (node.weights().isEmpty()) { double share = n > 0 ? slotsTotal / n : 0.0; for (int i = 0; i < n; i++) { slotWidths[i] = share; diff --git a/src/main/java/com/demcha/compose/document/layout/RowSlots.java b/src/main/java/com/demcha/compose/document/layout/RowSlots.java index 32cfb3b93..0acbe5497 100644 --- a/src/main/java/com/demcha/compose/document/layout/RowSlots.java +++ b/src/main/java/com/demcha/compose/document/layout/RowSlots.java @@ -1,18 +1,18 @@ package com.demcha.compose.document.layout; +import com.demcha.compose.document.node.DocumentNode; +import com.demcha.compose.document.style.DocumentRowColumn; + import java.util.List; /** - * Shared validation helpers for row weight / children distribution code. + * Shared row slot-width helpers for the compile and measure phases. * *

Centralises the {@link IllegalArgumentException} contract used by both * {@link LayoutCompiler#distributeRowSlotWidths(List, List, double, double) compile-phase} - * and {@link NodeDefinitionSupport#measureRow measure-phase} row distribution. - * The {@code RowNode} canonical constructor already rejects a mismatched - * weights list at construction time; these helpers are defence-in-depth - * for any path that bypasses the constructor (reflection-based - * deserialization, framework proxies, etc.) and arrives at the engine - * with an inconsistent {@code (weights, children)} pair.

+ * and {@link NodeDefinitionSupport#measureRow measure-phase} row distribution, + * and owns the explicit-column ({@code fixed / intrinsic / weight}) distribution + * so both phases compute the same slot widths from a single source.

* *

Package-private intentionally — engine surface, not public API.

* @@ -20,10 +20,110 @@ */ final class RowSlots { + private static final double EPS = 1e-6; + private RowSlots() { // Utility class, no instantiation. } + /** + * Inner width left for the slots after subtracting the inter-child gaps. + * + * @param innerWidth the row's content width + * @param gap gap between children + * @param childCount number of children + * @return the width available to distribute across the slots + */ + static double rowAvailableWidth(double innerWidth, double gap, int childCount) { + return Math.max(0.0, innerWidth - gap * Math.max(0, childCount - 1)); + } + + /** + * Distributes the row width across explicit columns: fixed columns take their + * point width, intrinsic columns take their measured natural width, and the + * remainder is shared across the weight columns. Called identically by the + * compile and measure phases (each supplies the same intrinsic widths), so a + * weight-only column list reduces to the same {@code available * (w / total)} + * split as plain weights. + * + * @param columns one width spec per child + * @param intrinsicWidths measured natural width per child (read only for + * intrinsic columns; other entries are ignored) + * @param gap gap between children + * @param innerWidth the row's content width + * @param rowName row name for the over-constrained error message + * @return resolved slot width per child + * @throws IllegalArgumentException if the fixed + intrinsic columns exceed the + * available width + */ + static double[] distributeColumns(List columns, + double[] intrinsicWidths, + double gap, + double innerWidth, + String rowName) { + int n = columns.size(); + double available = rowAvailableWidth(innerWidth, gap, n); + double[] slots = new double[n]; + double used = 0.0; + double totalWeight = 0.0; + for (int i = 0; i < n; i++) { + DocumentRowColumn column = columns.get(i); + switch (column.type()) { + case FIXED -> { + slots[i] = column.value(); + used += slots[i]; + } + case AUTO -> { + slots[i] = Math.max(0.0, intrinsicWidths[i]); + used += slots[i]; + } + case WEIGHT -> totalWeight += column.value(); + } + } + if (used > available + EPS) { + throw new IllegalArgumentException("Row '" + rowName + "' fixed and auto columns need " + + used + "pt but only " + available + + "pt is available. Reduce the fixed widths or the row content."); + } + double remaining = Math.max(0.0, available - used); + if (totalWeight > 0.0) { + for (int i = 0; i < n; i++) { + if (columns.get(i).type() == DocumentRowColumn.Type.WEIGHT) { + slots[i] = remaining * (columns.get(i).value() / totalWeight); + } + } + } + return slots; + } + + /** + * Measures the natural (content) outer width of each intrinsic column, for + * {@link #distributeColumns}. Both the compile and measure phases call this + * with the same {@code available} width and prepare context, so the resolved + * intrinsic widths — and therefore the slot widths — match. + * + * @param children the row's children + * @param columns one width spec per child + * @param available the row width available to the slots (after gaps) + * @param ctx the prepare context + * @return natural outer width per child (zero for non-intrinsic columns) + */ + static double[] intrinsicColumnWidths(List children, + List columns, + double available, + PrepareContext ctx) { + double[] intrinsic = new double[columns.size()]; + for (int i = 0; i < columns.size(); i++) { + if (columns.get(i).type() == DocumentRowColumn.Type.AUTO) { + DocumentNode child = children.get(i); + double childInner = Math.max(0.0, available - child.margin().horizontal()); + double natural = ctx.prepare(child, BoxConstraints.natural(childInner)).measureResult().width(); + intrinsic[i] = natural + child.margin().horizontal(); + } + } + return intrinsic; + } + /** * Asserts that an explicit {@code weights} list matches the row's * children count. Callers must skip this check when {@code weights} diff --git a/src/main/java/com/demcha/compose/document/node/RowNode.java b/src/main/java/com/demcha/compose/document/node/RowNode.java index d0899d21e..cf6b4d408 100644 --- a/src/main/java/com/demcha/compose/document/node/RowNode.java +++ b/src/main/java/com/demcha/compose/document/node/RowNode.java @@ -29,6 +29,9 @@ * @param stroke optional border stroke * @param cornerRadius optional render-only corner radius * @param borders optional per-side border strokes overriding the uniform stroke + * @param columns optional per-child column widths (fixed / intrinsic / weight); + * length must match children, or be empty. Mutually exclusive + * with {@code weights}. * @author Artem Demchyshyn */ public record RowNode( @@ -41,7 +44,8 @@ public record RowNode( DocumentColor fillColor, DocumentStroke stroke, DocumentCornerRadius cornerRadius, - DocumentBorders borders + DocumentBorders borders, + List columns ) implements DocumentNode { /** * Creates a normalized horizontal row container. @@ -60,6 +64,21 @@ public record RowNode( throw new IllegalArgumentException("RowNode weights must be positive finite numbers, got: " + weights); } } + columns = columns == null ? List.of() : List.copyOf(columns); + if (!columns.isEmpty()) { + if (columns.size() != children.size()) { + throw new IllegalArgumentException("RowNode columns size " + columns.size() + + " does not match children size " + children.size()); + } + for (DocumentRowColumn column : columns) { + if (column == null) { + throw new IllegalArgumentException("RowNode columns must not contain null."); + } + } + if (!weights.isEmpty()) { + throw new IllegalArgumentException("RowNode cannot set both weights and columns; use one."); + } + } padding = padding == null ? DocumentInsets.zero() : padding; margin = margin == null ? DocumentInsets.zero() : margin; cornerRadius = cornerRadius == null ? DocumentCornerRadius.ZERO : cornerRadius; @@ -69,6 +88,34 @@ public record RowNode( } } + /** + * Backwards-compatible constructor without per-child columns — defaults to an + * empty column list (weights / even split). + * + * @param name node name used in snapshots and layout graph paths + * @param children child semantic nodes in source order + * @param weights optional per-child weights (length must match children, or be empty) + * @param gap horizontal gap between children + * @param padding inner padding + * @param margin outer margin + * @param fillColor optional background fill + * @param stroke optional border stroke + * @param cornerRadius optional render-only corner radius + * @param borders optional per-side border strokes + */ + public RowNode(String name, + List children, + List weights, + double gap, + DocumentInsets padding, + DocumentInsets margin, + DocumentColor fillColor, + DocumentStroke stroke, + DocumentCornerRadius cornerRadius, + DocumentBorders borders) { + this(name, children, weights, gap, padding, margin, fillColor, stroke, cornerRadius, borders, List.of()); + } + /** * Backwards-compatible constructor without per-side borders. * @@ -91,6 +138,6 @@ public RowNode(String name, DocumentColor fillColor, DocumentStroke stroke, DocumentCornerRadius cornerRadius) { - this(name, children, weights, gap, padding, margin, fillColor, stroke, cornerRadius, DocumentBorders.NONE); + this(name, children, weights, gap, padding, margin, fillColor, stroke, cornerRadius, DocumentBorders.NONE, List.of()); } } diff --git a/src/main/java/com/demcha/compose/document/style/DocumentRowColumn.java b/src/main/java/com/demcha/compose/document/style/DocumentRowColumn.java new file mode 100644 index 000000000..8b0615c2e --- /dev/null +++ b/src/main/java/com/demcha/compose/document/style/DocumentRowColumn.java @@ -0,0 +1,83 @@ +package com.demcha.compose.document.style; + +/** + * Per-child column width for a {@code row(...)} — a fixed point width, an + * automatic (content-sized) width, or a weight that shares the leftover space. + * The row peer of {@link com.demcha.compose.document.table.DocumentTableColumn}; + * resolution order is fixed → auto → weight-remainder. + * + *

An {@link Type#AUTO auto} column takes its content's natural width up to + * the row width — content still wraps if it cannot fit, it does not overflow. + * Fixed and auto columns are left-packed: if they do not fill the row, the + * trailing space is left empty unless a {@link #weight(double) weight} column is + * present to absorb the remainder. A weight-only column list resolves identically + * to plain {@code weights(...)}, which remains the sugar for the common even / + * weighted split. Instances are immutable and thread-safe.

+ * + * @param type the sizing strategy + * @param value points for {@code FIXED}, the weight for {@code WEIGHT}, or + * {@code null} for {@code AUTO} + * @author Artem Demchyshyn + * @since 1.9.0 + */ +public record DocumentRowColumn(Type type, Double value) { + + /** How a row column is sized. */ + public enum Type { + /** Width is the column's content natural width, capped at the row width. */ + AUTO, + /** Width is a fixed point value. */ + FIXED, + /** Width is a share of the space left after the fixed and auto columns. */ + WEIGHT + } + + /** + * Validates the type/value pairing. + */ + public DocumentRowColumn { + if (type == null) { + throw new IllegalArgumentException("Column type cannot be null."); + } + if (type == Type.FIXED && !isPositiveFinite(value)) { + throw new IllegalArgumentException("Fixed column width must be a positive finite number, got: " + value); + } + if (type == Type.WEIGHT && !isPositiveFinite(value)) { + throw new IllegalArgumentException("Column weight must be a positive finite number, got: " + value); + } + } + + /** + * A column sized to a fixed point width. + * + * @param points width in points (must be positive) + * @return a fixed column + */ + public static DocumentRowColumn fixed(double points) { + return new DocumentRowColumn(Type.FIXED, points); + } + + /** + * A column sized to its content's natural width (capped at the row width). + * + * @return an auto column + */ + public static DocumentRowColumn auto() { + return new DocumentRowColumn(Type.AUTO, null); + } + + /** + * A column that takes a share of the space left after the fixed and auto + * columns, proportional to {@code weight}. + * + * @param weight relative share (must be positive) + * @return a weight column + */ + public static DocumentRowColumn weight(double weight) { + return new DocumentRowColumn(Type.WEIGHT, weight); + } + + private static boolean isPositiveFinite(Double value) { + return value != null && value > 0.0 && !Double.isNaN(value) && !Double.isInfinite(value); + } +} diff --git a/src/test/java/com/demcha/compose/document/dsl/RowColumnsTest.java b/src/test/java/com/demcha/compose/document/dsl/RowColumnsTest.java new file mode 100644 index 000000000..c509cc2b6 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/dsl/RowColumnsTest.java @@ -0,0 +1,165 @@ +package com.demcha.compose.document.dsl; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.layout.PlacedNode; +import com.demcha.compose.document.node.RowNode; +import com.demcha.compose.document.node.SpacerNode; +import com.demcha.compose.document.style.DocumentInsets; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.function.Consumer; + +import static com.demcha.compose.document.style.DocumentRowColumn.auto; +import static com.demcha.compose.document.style.DocumentRowColumn.fixed; +import static com.demcha.compose.document.style.DocumentRowColumn.weight; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.within; + +/** + * {@link RowBuilder#columns(DocumentRowColumn...)}: fixed columns take their point + * width, intrinsic ({@code auto()}) columns size to their content, and weight + * columns share the remainder — the layout behind a dot-leader row. A weight-only + * column list resolves identically to plain {@code weights(...)}. + * + *

Inner content width is {@code 240 - 2*20 = 200pt}; fill lines report their + * slot width and named text reports its content width, so both make the resolved + * column widths observable.

+ */ +class RowColumnsTest { + + @Test + void fixedColumnTakesItsPointWidthAndWeightTakesTheRemainder() { + render(page -> page.addRow(r -> r.gap(0).columns(fixed(40), weight(1)) + .addLine(l -> l.name("a").fill()) + .addLine(l -> l.name("b").fill())), session -> { + assertThat(widthOf(session, "a")).isCloseTo(40.0, within(0.5)); + assertThat(widthOf(session, "b")).isCloseTo(160.0, within(0.5)); + }); + } + + @Test + void weightOnlyColumnsResolveLikePlainWeights() { + render(page -> page.addRow(r -> r.gap(0).columns(weight(1), weight(3)) + .addLine(l -> l.name("a").fill()) + .addLine(l -> l.name("b").fill())), session -> { + // weights(1, 3) over 200pt -> 50 / 150; columns(weight(1), weight(3)) must match. + assertThat(widthOf(session, "a")).isCloseTo(50.0, within(0.5)); + assertThat(widthOf(session, "b")).isCloseTo(150.0, within(0.5)); + }); + } + + @Test + void intrinsicColumnSizesToContentAndWeightTakesRemainder() { + render(page -> page.addRow(r -> r.gap(0).columns(auto(), weight(1)) + .addParagraph(p -> p.name("label").text("Hi")) + .addLine(l -> l.name("rest").fill())), session -> { + double label = widthOf(session, "label"); + double rest = widthOf(session, "rest"); + assertThat(label).isGreaterThan(0.0).isLessThan(100.0); // content-sized, not a half split + assertThat(rest).isCloseTo(200.0 - label, within(0.5)); // weight took the remainder + }); + } + + @Test + void overConstrainedFixedColumnsThrow() { + try (DocumentSession session = smallPage()) { + session.pageFlow(page -> page.addRow(r -> r.gap(0).columns(fixed(150), fixed(150)) + .addLine(l -> l.name("a").fill()) + .addLine(l -> l.name("b").fill()))); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(session::layoutGraph) + .withMessageContaining("available"); + } + } + + @Test + void builderRejectsColumnCountMismatch() { + try (DocumentSession session = smallPage()) { + assertThatExceptionOfType(IllegalStateException.class) + .isThrownBy(() -> session.pageFlow(page -> page.addRow(r -> r.columns(auto()) + .addParagraph("a") + .addParagraph("b")))) + .withMessageContaining("columns size"); + } + } + + @Test + void rowNodeRejectsBothWeightsAndColumns() { + SpacerNode a = new SpacerNode("a", 10, 10, DocumentInsets.zero(), DocumentInsets.zero()); + SpacerNode b = new SpacerNode("b", 10, 10, DocumentInsets.zero(), DocumentInsets.zero()); + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> new RowNode("r", List.of(a, b), List.of(1.0, 1.0), + 0, null, null, null, null, null, null, List.of(auto(), auto()))) + .withMessageContaining("both"); + } + + @Test + void dotLeaderRowFillsBetweenTheLabelAndTheNumber() { + double gap = 4; + render(page -> page.addRow(r -> r.gap(gap).columns(auto(), weight(1), auto()) + .addParagraph(p -> p.name("label").text("Chapter 1")) + .addLine(l -> l.name("leader").fill()) + .addParagraph(p -> p.name("num").text("12"))), session -> { + double label = widthOf(session, "label"); + double leader = widthOf(session, "leader"); + double num = widthOf(session, "num"); + // The leader fills exactly what is left after the two content columns and the gaps. + assertThat(leader).isGreaterThan(0.0); + assertThat(label + leader + num + 2 * gap).isCloseTo(200.0, within(1.0)); + }); + } + + @Test + void weightColumnsAreByteIdenticalToPlainWeights() { + double[] viaWeights = twoFillWidths(r -> r.gap(0).weights(1, 3)); + double[] viaColumns = twoFillWidths(r -> r.gap(0).columns(weight(1), weight(3))); + // The weight-only column path must resolve to exactly the legacy weights result. + assertThat(viaColumns[0]).isEqualTo(viaWeights[0]); + assertThat(viaColumns[1]).isEqualTo(viaWeights[1]); + } + + @Test + void longAutoLabelClampsToTheRowWithoutOverflowing() { + // An auto column whose content is wider than the row clamps to the row + // width (the content wraps) instead of overflowing or throwing. + try (DocumentSession session = smallPage()) { + session.pageFlow(page -> page.addRow(r -> r.gap(0).columns(auto()) + .addParagraph(p -> p.name("wide") + .text("a very long single column label that far exceeds the row width")))); + assertThat(widthOf(session, "wide")).isLessThanOrEqualTo(200.0 + 0.5); + } + } + + private double[] twoFillWidths(Consumer spec) { + try (DocumentSession session = smallPage()) { + session.pageFlow(page -> page.addRow(r -> { + spec.accept(r); + r.addLine(l -> l.name("a").fill()); + r.addLine(l -> l.name("b").fill()); + })); + return new double[]{widthOf(session, "a"), widthOf(session, "b")}; + } + } + + private DocumentSession smallPage() { + return GraphCompose.document().pageSize(240, 200).margin(DocumentInsets.of(20)).create(); + } + + private void render(Consumer flow, Consumer assertions) { + try (DocumentSession session = smallPage()) { + session.pageFlow(flow); + assertions.accept(session); + } + } + + private double widthOf(DocumentSession session, String name) { + PlacedNode node = session.layoutGraph().nodes().stream() + .filter(n -> name.equals(n.semanticName())) + .findFirst() + .orElseThrow(() -> new AssertionError(name + " not placed")); + return node.placementWidth(); + } +}