diff --git a/CHANGELOG.md b/CHANGELOG.md index 708c39893..a3dc714b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ PDF `GoTo` actions. External links are unchanged. ### Public API +- **`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 + (`dashed(0.1, 4).lineCap(ROUND)`) it is the flex leader behind a + table-of-contents row, drawn without measuring the gap by hand. A non-fill line + is unchanged, so existing line output stays byte-identical. + - **Negative-margin handling** (`@since 1.9.0`). A negative **page** margin (`DocumentSession.margin(...)` or the builder's `margin(...)`) is now rejected with an `IllegalArgumentException` — it would make the content area larger than diff --git a/assets/readme/examples/line-fill.pdf b/assets/readme/examples/line-fill.pdf new file mode 100644 index 000000000..a52746644 Binary files /dev/null and b/assets/readme/examples/line-fill.pdf differ diff --git a/examples/README.md b/examples/README.md index 867269018..8772780c6 100644 --- a/examples/README.md +++ b/examples/README.md @@ -93,6 +93,7 @@ are with the canonical DSL, then jump to its detailed section below. | [Shape containers](#shape-containers) | Circles, ellipses, rounded cards with `ClipPolicy.CLIP_PATH` | [PDF](../assets/readme/examples/shape-container.pdf) · [Source](src/main/java/com/demcha/examples/features/shapes/ShapeContainerExample.java) | | [Vector paths (Bézier)](#vector-paths-bézier) | `addPath(...)` + `SvgPath.parse(...)` — design shapes and imported SVG icons as native curves; zero tessellation | [PDF](../assets/readme/examples/vector-path.pdf) · [Source](src/main/java/com/demcha/examples/features/shapes/VectorPathExample.java) | | [Line caps & dotted lines](#line-caps--dotted-lines) | `line.lineCap(ROUND)` — round / square caps for plain lines; `dashed(0.1, 4).lineCap(ROUND)` draws a dotted leader | [PDF](../assets/readme/examples/line-cap.pdf) · [Source](src/main/java/com/demcha/examples/features/shapes/LineCapExample.java) | +| [Fill lines & dot leaders](#fill-lines--dot-leaders) | `line().fill()` — a line stretches to its row column or the content width; pair with `dashed(...).lineCap(ROUND)` for a dot leader drawn without measuring the gap | [PDF](../assets/readme/examples/line-fill.pdf) · [Source](src/main/java/com/demcha/examples/features/shapes/LineFillExample.java) | | [SVG icon gallery](#svg-icon-gallery) | 34 real-world multicolour svgrepo icons via `SvgIcon.parse` — up to 19 layers each, the whole set 156 KB of sources | [PDF](../assets/readme/examples/svg-icon-gallery.pdf) · [Source](src/main/java/com/demcha/examples/features/svg/SvgIconGalleryExample.java) | | [Advanced tables](#advanced-tables) | Row span, zebra rows, totals, repeating header on page break | [PDF](../assets/readme/examples/table-advanced.pdf) · [Source](src/main/java/com/demcha/examples/features/tables/TableAdvancedExample.java) | | [Barcodes](#barcodes) | QR, Code 128, Code 39, EAN-13, EAN-8, branded QR with theme colours | [PDF](../assets/readme/examples/barcode-showcase.pdf) · [Source](src/main/java/com/demcha/examples/features/barcodes/BarcodeShowcaseExample.java) | @@ -404,6 +405,23 @@ flow.addLine(l -> l.horizontal(w).stroke(stroke) [📄 View PDF](../assets/readme/examples/line-cap.pdf) · [📜 Full source](src/main/java/com/demcha/examples/features/shapes/LineCapExample.java) +### Fill lines & dot leaders + +`LineBuilder.fill()` stretches a line to the width available where it is placed — +the content width at flow level, or its column inside a row — instead of an +authored fixed width. Paired with a dotted stroke it is the flex leader behind a +table-of-contents row, drawn without measuring the gap by hand. A non-fill line +keeps its fixed width, so existing line output stays byte-identical. + +```java +flow.addRow(r -> r.weights(5, 1) + .addLine(l -> l.fill().stroke(s).dashed(0.1, 4).lineCap(DocumentLineCap.ROUND)) // leader fills its column + .addParagraph("p. 12")); +``` + +[📄 View PDF](../assets/readme/examples/line-fill.pdf) · +[📜 Full source](src/main/java/com/demcha/examples/features/shapes/LineFillExample.java) + ### SVG icon gallery A stress-test sheet for the beta SVG reader: 34 real-world multicolour diff --git a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java index ef7b459b0..301f62812 100644 --- a/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java +++ b/examples/src/main/java/com/demcha/examples/GenerateAllExamples.java @@ -11,6 +11,7 @@ import com.demcha.examples.features.layout.BlockAlignExample; import com.demcha.examples.features.lists.NestedListExample; import com.demcha.examples.features.shapes.LineCapExample; +import com.demcha.examples.features.shapes.LineFillExample; import com.demcha.examples.features.shapes.PhotoClipExample; import com.demcha.examples.features.shapes.ShapeContainerExample; import com.demcha.examples.features.shapes.VectorPathExample; @@ -147,6 +148,7 @@ public static void main(String[] args) throws Exception { System.out.println("Generated: " + ShapeContainerExample.generate()); System.out.println("Generated: " + VectorPathExample.generate()); System.out.println("Generated: " + LineCapExample.generate()); + System.out.println("Generated: " + LineFillExample.generate()); System.out.println("Generated: " + PhotoClipExample.generate()); System.out.println("Generated: " + SvgIconGalleryExample.generate()); System.out.println("Generated: " + BlockAlignExample.generate()); diff --git a/examples/src/main/java/com/demcha/examples/features/shapes/LineFillExample.java b/examples/src/main/java/com/demcha/examples/features/shapes/LineFillExample.java new file mode 100644 index 000000000..d7fb946b1 --- /dev/null +++ b/examples/src/main/java/com/demcha/examples/features/shapes/LineFillExample.java @@ -0,0 +1,87 @@ +package com.demcha.examples.features.shapes; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +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; + +/** + * Runnable showcase for v1.9 {@code LineBuilder.fill()}: a line stretches to the + * width available where it is placed — the content width at flow level, or its + * slot inside a row — instead of an authored fixed width. Paired with a dotted + * stroke it is the flex leader behind a table-of-contents row, drawn without + * measuring the gap by hand. + * + *
{@code
+ * flow.addLine(l -> l.fill().stroke(s).dashed(0.1, 4).lineCap(ROUND));  // full-width dots
+ * flow.addRow(r -> r.weights(4, 1)
+ *     .addLine(l -> l.fill().stroke(s).dashed(0.1, 4).lineCap(ROUND))   // leader fills its column
+ *     .addParagraph("p. 12"));
+ * }
+ * + * @author Artem Demchyshyn + */ +public final class LineFillExample { + + private static final DocumentColor INK = DocumentColor.rgb(24, 28, 38); + private static final DocumentColor MUTED = DocumentColor.rgb(90, 96, 105); + + private LineFillExample() { + } + + /** + * Renders the fill sheet: a full-width rule, a full-width dotted leader, and + * a weighted leader-to-number row that previews a table-of-contents line. + * + * @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/shapes", "line-fill.pdf"); + + DocumentTextStyle caption = DocumentTextStyle.DEFAULT.withSize(10).withColor(MUTED); + DocumentStroke rule = DocumentStroke.of(INK, 1.2); + DocumentStroke dots = DocumentStroke.of(MUTED, 1.4); + + try (DocumentSession document = GraphCompose.document(pdfFile) + .pageSize(400, 260) + .margin(DocumentInsets.of(36)) + .create()) { + document.pageFlow(page -> { + page.addParagraph(p -> p + .text("Fill lines & dot leaders") + .textStyle(DocumentTextStyle.DEFAULT.withSize(18))); + + page.addParagraph(p -> p.text("line().fill() — rule spans the content width") + .textStyle(caption).padding(DocumentInsets.top(12))); + page.addLine(l -> l.fill().height(2).stroke(rule)); + + page.addParagraph(p -> p.text("line().fill().dashed(0.1, 4).lineCap(ROUND) — dotted leader") + .textStyle(caption).padding(DocumentInsets.top(12))); + page.addLine(l -> l.fill().height(2).stroke(dots) + .dashed(0.1, 4).lineCap(DocumentLineCap.ROUND)); + + page.addParagraph(p -> p.text("weighted row: leader fills the gap up to the page number") + .textStyle(caption).padding(DocumentInsets.top(12))); + page.addRow(r -> r.gap(6).weights(5, 1) + .addLine(l -> l.fill().height(8).stroke(dots) + .dashed(0.1, 4).lineCap(DocumentLineCap.ROUND)) + .addParagraph(p -> p.text("p. 12").textStyle(caption))); + }); + + document.buildPdf(); + } + + return pdfFile; + } + + public static void main(String[] args) throws Exception { + System.out.println("Generated: " + generate()); + } +} diff --git a/src/main/java/com/demcha/compose/document/dsl/LineBuilder.java b/src/main/java/com/demcha/compose/document/dsl/LineBuilder.java index f5f9aaffa..f01eb7f83 100644 --- a/src/main/java/com/demcha/compose/document/dsl/LineBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/LineBuilder.java @@ -31,6 +31,7 @@ public final class LineBuilder implements Transformable { private DocumentTransform transform = DocumentTransform.NONE; private DocumentDashPattern dashPattern = DocumentDashPattern.NONE; private DocumentLineCap lineCap = DocumentLineCap.BUTT; + private boolean fillWidth = false; /** * Creates a line builder. @@ -259,6 +260,26 @@ public LineBuilder lineCap(DocumentLineCap lineCap) { return this; } + /** + * Stretches a horizontal line to fill the width available where it is placed, + * instead of its fixed {@link #width(double)}. In a row column the line spans + * the whole slot; at flow level it spans the content width. This is the flex + * line behind a dot leader — pair it with {@code dashed(...)} (and + * {@code lineCap(ROUND)} for dots) so the leader runs from one column to the + * next without computing the gap width by hand. + * + *

Applies to horizontal lines only (the default geometry); on a vertical + * or diagonal line it is a no-op, since stretching the end point would change + * the line's slope.

+ * + * @return this builder + * @since 1.9.0 + */ + public LineBuilder fill() { + this.fillWidth = true; + return this; + } + /** * Attaches line-level external link metadata. * @@ -382,7 +403,8 @@ public LineNode build() { transform, dashPattern, anchor, - lineCap); + lineCap, + fillWidth); } private boolean isHorizontalLine() { diff --git a/src/main/java/com/demcha/compose/document/layout/definitions/LineDefinition.java b/src/main/java/com/demcha/compose/document/layout/definitions/LineDefinition.java index eb457b771..f62af1a46 100644 --- a/src/main/java/com/demcha/compose/document/layout/definitions/LineDefinition.java +++ b/src/main/java/com/demcha/compose/document/layout/definitions/LineDefinition.java @@ -29,11 +29,25 @@ public Class nodeType() { @Override public PreparedNode prepare(LineNode node, PrepareContext ctx, BoxConstraints constraints) { + // A horizontal fill line claims the width available where it is placed (its + // row slot, or the content width) rather than its authored fixed width. + double outerWidth = isFill(node) + ? constraints.availableWidth() + : node.width() + node.padding().horizontal(); return PreparedNode.leaf(node, new MeasureResult( - node.width() + node.padding().horizontal(), + outerWidth, node.height() + node.padding().vertical())); } + /** + * Fill applies only to horizontal lines — stretching the end point of a + * vertical or diagonal line would silently change its geometry, so fill is a + * no-op there. + */ + private static boolean isFill(LineNode node) { + return node.fillWidth() && Math.abs(node.startY() - node.endY()) <= EPS; + } + @Override public PaginationPolicy paginationPolicy(LineNode node) { return PaginationPolicy.ATOMIC; @@ -49,6 +63,9 @@ public List emitFragments(PreparedNode prepared, if (width <= EPS && height <= EPS) { return List.of(); } + // A fill line is stretched to its resolved box, so it ends at the box's + // right content edge instead of the authored endX. + double endX = isFill(node) ? width : node.endX(); LayoutFragment leaf = new LayoutFragment( placement.path(), 0, @@ -60,7 +77,7 @@ public List emitFragments(PreparedNode prepared, toStroke(node.stroke()), node.startX(), node.startY(), - node.endX(), + endX, node.endY(), node.linkTarget(), node.bookmarkOptions(), diff --git a/src/main/java/com/demcha/compose/document/node/LineNode.java b/src/main/java/com/demcha/compose/document/node/LineNode.java index bad0fef8e..51ed95ee9 100644 --- a/src/main/java/com/demcha/compose/document/node/LineNode.java +++ b/src/main/java/com/demcha/compose/document/node/LineNode.java @@ -30,6 +30,10 @@ * @param lineCap end-cap style for the stroke; defaults to * {@link DocumentLineCap#BUTT}. {@code ROUND} turns a * dashed stroke into a dotted line + * @param fillWidth when {@code true}, the line stretches to the width + * available where it is placed (its row slot, or the + * content width) instead of {@code width}; the flex line + * behind a dot leader. Defaults to {@code false}. * @author Artem Demchyshyn */ public record LineNode( @@ -48,7 +52,8 @@ public record LineNode( DocumentTransform transform, DocumentDashPattern dashPattern, String anchor, - DocumentLineCap lineCap + DocumentLineCap lineCap, + boolean fillWidth ) implements DocumentNode { /** * Normalizes spacing defaults and validates explicit line geometry. @@ -69,6 +74,47 @@ public record LineNode( requireFinite(endY, "endY"); } + /** + * Backward-compatible canonical constructor without the fill flag — defaults + * to {@code false} (a fixed-width, byte-identical line). + * + * @param name node name used in snapshots and layout graph paths + * @param width resolved line box width + * @param height resolved line box height + * @param startX line start x offset inside the box + * @param startY line start y offset inside the box + * @param endX line end x offset inside the box + * @param endY line end y offset inside the box + * @param stroke line stroke descriptor + * @param linkTarget optional node-level link target + * @param bookmarkOptions optional node-level bookmark metadata + * @param padding inner padding + * @param margin outer margin + * @param transform render-time affine transform + * @param dashPattern dash pattern for the stroke + * @param anchor optional navigation anchor name + * @param lineCap end-cap style for the stroke + */ + public LineNode(String name, + double width, + double height, + double startX, + double startY, + double endX, + double endY, + DocumentStroke stroke, + DocumentLinkTarget linkTarget, + DocumentBookmarkOptions bookmarkOptions, + DocumentInsets padding, + DocumentInsets margin, + DocumentTransform transform, + DocumentDashPattern dashPattern, + String anchor, + DocumentLineCap lineCap) { + this(name, width, height, startX, startY, endX, endY, stroke, linkTarget, bookmarkOptions, + padding, margin, transform, dashPattern, anchor, lineCap, false); + } + /** * Backward-compatible canonical constructor without the line cap — defaults * to {@link DocumentLineCap#BUTT} (a squared, byte-identical end). @@ -105,7 +151,7 @@ public LineNode(String name, DocumentDashPattern dashPattern, String anchor) { this(name, width, height, startX, startY, endX, endY, stroke, linkTarget, bookmarkOptions, - padding, margin, transform, dashPattern, anchor, DocumentLineCap.BUTT); + padding, margin, transform, dashPattern, anchor, DocumentLineCap.BUTT, false); } /** diff --git a/src/test/java/com/demcha/compose/document/dsl/LineFillTest.java b/src/test/java/com/demcha/compose/document/dsl/LineFillTest.java new file mode 100644 index 000000000..d2f6dc13a --- /dev/null +++ b/src/test/java/com/demcha/compose/document/dsl/LineFillTest.java @@ -0,0 +1,80 @@ +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.style.DocumentInsets; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +/** + * {@link LineBuilder#fill()} stretches a line to the width available where it is + * placed (its row slot or the content width) instead of its authored width — the + * flex line behind a dot leader. A non-fill line keeps its fixed width. + */ +class LineFillTest { + + @Test + void fillLineStretchesToItsRowSlotWhileAFixedLineKeepsItsWidth() { + double filled = lineWidthInTwoEqualColumns(true); + double fixed = lineWidthInTwoEqualColumns(false); + + // inner width = 240 - 2*20 = 200; gap 0; two equal weights => 100pt slots. + assertThat(filled).isCloseTo(100.0, within(0.5)); + // A non-fill line keeps its authored width (LineBuilder default 1.0). + assertThat(fixed).isCloseTo(1.0, within(0.5)); + } + + @Test + void fillLineSpansTheContentWidthAtFlowLevel() { + try (DocumentSession session = GraphCompose.document() + .pageSize(240, 200) + .margin(DocumentInsets.of(20)) + .create()) { + session.pageFlow(page -> page.addLine(l -> l.name("rule").height(2).fill())); + assertThat(placedWidth(session, "rule")).isCloseTo(200.0, within(0.5)); + } + } + + @Test + void fillIsANoOpForAVerticalLine() { + try (DocumentSession session = GraphCompose.document() + .pageSize(240, 200) + .margin(DocumentInsets.of(20)) + .create()) { + // Stretching the end point would change the slope, so fill leaves a + // vertical line at its authored (stroke-width) box instead of 200pt. + session.pageFlow(page -> page.addLine(l -> l.name("rule").vertical(30).fill())); + assertThat(placedWidth(session, "rule")).isLessThan(20.0); + } + } + + private double lineWidthInTwoEqualColumns(boolean fill) { + try (DocumentSession session = GraphCompose.document() + .pageSize(240, 200) + .margin(DocumentInsets.of(20)) + .create()) { + session.pageFlow(page -> page.addRow(r -> { + r.gap(0).weights(1, 1); + r.addLine(l -> { + l.name("rule").height(2); + if (fill) { + l.fill(); + } + }); + r.addParagraph("X"); + })); + return placedWidth(session, "rule"); + } + } + + private double placedWidth(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(); + } +}