From 1ff3aa24f47168e9b62dd47e3e231bfd472edaeb Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Thu, 25 Jun 2026 14:03:41 +0100
Subject: [PATCH] =?UTF-8?q?feat(api):=20RowBuilder.columns()=20=E2=80=94?=
=?UTF-8?q?=20fixed=20/=20auto=20/=20weight=20row=20columns?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A row split its width by weights or evenly; there was no way to size a
column to its content or to a fixed width, so a table-of-contents row
(label, dotted leader, page number) was not expressible. columns(...)
sizes each column as DocumentRowColumn.fixed(pt), auto() (content width),
or weight(w) (a share of the remainder), resolved fixed -> auto ->
weight. With line().fill() it draws a dot leader that fills the gap.
The explicit-column distribution lives in a shared RowSlots helper called
by both the compile and measure phases, so the two stay in lockstep; the
existing weight / even path is untouched and a weight-only column list
resolves to exactly the same widths, so existing rows are byte-identical.
weights(...) stays as sugar; columns and weights are mutually exclusive.
Tests: RowColumnsTest covers the fixed/auto/weight mix, weight-only
columns byte-identical to plain weights, content sizing plus the weight
remainder, over-constrained columns throwing, a long auto label clamping
to the row, and the dot-leader table-of-contents row. Example:
RowColumnsExample (a table-of-contents block). Full suite green, no
visual baselines changed.
---
CHANGELOG.md | 9 +
assets/readme/examples/row-columns.pdf | Bin 0 -> 1152 bytes
examples/README.md | 19 ++
.../demcha/examples/GenerateAllExamples.java | 2 +
.../features/layout/RowColumnsExample.java | 100 +++++++++++
.../compose/document/dsl/RowBuilder.java | 39 ++++-
.../document/layout/LayoutCompiler.java | 14 +-
.../layout/NodeDefinitionSupport.java | 5 +-
.../compose/document/layout/RowSlots.java | 114 +++++++++++-
.../demcha/compose/document/node/RowNode.java | 51 +++++-
.../document/style/DocumentRowColumn.java | 83 +++++++++
.../compose/document/dsl/RowColumnsTest.java | 165 ++++++++++++++++++
12 files changed, 588 insertions(+), 13 deletions(-)
create mode 100644 assets/readme/examples/row-columns.pdf
create mode 100644 examples/src/main/java/com/demcha/examples/features/layout/RowColumnsExample.java
create mode 100644 src/main/java/com/demcha/compose/document/style/DocumentRowColumn.java
create mode 100644 src/test/java/com/demcha/compose/document/dsl/RowColumnsTest.java
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 0000000000000000000000000000000000000000..cdbbb871e093b2d559ed0421de22465cff7d3894
GIT binary patch
literal 1152
zcmY!laBZ^4=fsl4ocwey{jk)c;>`R!
z1$~fe{eZ;u)M5oApzn?MGE?EIf*5y
zE~&}+DXAb`#U(|liMhO76?4{3J{#0*AkzB1vTOH}#m>dfKTci>d;7xgv|o(E5?wxC
zU6$96rLCHJj)}^OneS9oe_k_L=A@EZ90SkArHKrMe$T#hNI33M{2cu=Fy&L_)`m5T
zBD>a~>=0g_zqBQbIdbWikXefqZqDy@J@j7Mt`%1zs#P|`{GXk^SLH=jfs($Pu*Dv!4ok-h6_1@a
zA4`|-`#GJPvHH=*0|k#4$9Z034)3qk_v>sHvs0X{uwzn`couiBU$*Y&ZY8ISvMD^D
z_II?+>=R`#9cQoN&Yd^m-cqSoVWtH)XCGZrurl4IS~=?q
zZ*#|^71qzv7JNG$+@!R5!RMDECGE{2l^-iT?GD8VZDy=Zdu&|twnlAf;zj>HeUYm!
zYj{D?2Z;+iC3v*+0BR2yB0}E3(S7Q@L3s)02Q)44bXHzF*b7MDWX9Gt^
zCs&}Tqlty9rJIX^g`EQBM#REmE+Qy3jY~f`vno}=&;aPbyp+@mg=hl>OQ4Eyg=j-y
zjy41`3Fg=2goGdGPaHUZ;K%{SLp=^>j+}VFo@C5y$gMotf|)^Eo^dOd5GyW8EGnre
R0=mG$2w32#y863u0RU#suJ`}|
literal 0
HcmV?d00001
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();
+ }
+}