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 ListFixed 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(PreparedNodeCentralises 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(ListAn {@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