diff --git a/CHANGELOG.md b/CHANGELOG.md index b929ccf46..9e7140d98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,7 +60,9 @@ Entries land here as they merge. zoom level instead of being tessellated into straight pieces. Atomic pagination, deterministic layout snapshots, fill (non-zero winding rule) and/or stroke. This is the leaf vehicle for smooth chart lines, decorative - design shapes, and future SVG path import. + design shapes, and future SVG path import. DSL: + `addPath(p -> p.moveTo(...).curveTo(...).closePath().fillColor(...))` on + every flow builder authors design shapes directly. - **Inline sparklines** (`@since 1.8.0`). `RichText.sparkline(w, h, color, values...)` draws a filled mini-area silhouette on the text baseline, and `sparklineLine(w, h, thickness, color, values...)` a constant-thickness line diff --git a/assets/readme/examples/vector-path.pdf b/assets/readme/examples/vector-path.pdf new file mode 100644 index 000000000..8324e9e7f Binary files /dev/null and b/assets/readme/examples/vector-path.pdf differ diff --git a/examples/README.md b/examples/README.md index f7b7893f3..73d2410b9 100644 --- a/examples/README.md +++ b/examples/README.md @@ -88,6 +88,7 @@ are with the canonical DSL, then jump to its detailed section below. | Example | What it shows | Preview · Source | |---|---|---| | [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(...)` — design shapes with native cubic curves: waves, blobs, ribbons; zero tessellation | [PDF](../assets/readme/examples/vector-path.pdf) · [Source](src/main/java/com/demcha/examples/features/shapes/VectorPathExample.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) | | [Charts](#charts) | Native vector bar, line, and pie/donut charts — data/spec/style layers, axis & grid toggles, point markers, value labels, legend | [PDF](../assets/readme/examples/chart-showcase.pdf) · [Source](src/main/java/com/demcha/examples/features/charts/ChartShowcaseExample.java) | @@ -354,6 +355,27 @@ layers both gain `int zIndex` (default `0`). [📄 View PDF](../assets/readme/examples/transforms.pdf) · [📜 Full source](src/main/java/com/demcha/examples/features/transforms/TransformsExample.java) +### Vector paths (Bézier) + +Free-form design shapes with native cubic Bézier curves through +`addPath(...)`: stroked waves, filled blobs, and mixed line/curve +ribbons in one closed subpath. Curves render as native PDF `curveTo` +operators — perfectly smooth at any zoom, no tessellation. Coordinates +are normalized to the shape's box (`(0,0)` bottom-left, `y` up) and +control points may overshoot it. + +```java +flow.addPath(path -> path + .size(320, 60) + .moveTo(0.0, 0.5) + .curveTo(0.25, 1.0, 0.25, 0.0, 0.5, 0.5) + .curveTo(0.75, 1.0, 0.75, 0.0, 1.0, 0.5) + .stroke(DocumentStroke.of(accent, 2.4))); +``` + +[📄 View PDF](../assets/readme/examples/vector-path.pdf) · +[📜 Full source](src/main/java/com/demcha/examples/features/shapes/VectorPathExample.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 645189d91..6930131ac 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.PdfChromeExample; import com.demcha.examples.features.lists.NestedListExample; import com.demcha.examples.features.shapes.ShapeContainerExample; +import com.demcha.examples.features.shapes.VectorPathExample; import com.demcha.examples.features.snapshots.LayoutSnapshotRegressionExample; import com.demcha.examples.features.streaming.HttpStreamingExample; import com.demcha.examples.features.tables.ComposedTableCellExample; @@ -128,6 +129,7 @@ public static void main(String[] args) throws Exception { // v1.5 visual primitives System.out.println("Generated: " + ShapeContainerExample.generate()); + System.out.println("Generated: " + VectorPathExample.generate()); System.out.println("Generated: " + TransformsExample.generate()); System.out.println("Generated: " + TableAdvancedExample.generate()); diff --git a/examples/src/main/java/com/demcha/examples/features/shapes/VectorPathExample.java b/examples/src/main/java/com/demcha/examples/features/shapes/VectorPathExample.java new file mode 100644 index 000000000..eb32c04e1 --- /dev/null +++ b/examples/src/main/java/com/demcha/examples/features/shapes/VectorPathExample.java @@ -0,0 +1,102 @@ +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.DocumentStroke; +import com.demcha.examples.support.ExampleOutputPaths; + +import java.nio.file.Path; + +/** + * Runnable showcase for the v1.8 vector-path primitive: free-form design + * shapes with native cubic Bézier curves, authored through the + * {@code addPath(...)} DSL. + * + *
{@code
+ * flow.addPath(path -> path
+ *         .size(320, 60)
+ *         .moveTo(0.0, 0.5)
+ *         .curveTo(0.25, 1.0, 0.25, 0.0, 0.5, 0.5)
+ *         .curveTo(0.75, 1.0, 0.75, 0.0, 1.0, 0.5)
+ *         .stroke(DocumentStroke.of(accent, 2.4)));
+ * }
+ * + *

Curves render as native PDF {@code curveTo} operators — perfectly + * smooth at any zoom, no tessellation. Coordinates are normalized to the + * shape's box ({@code (0,0)} bottom-left, {@code y} up) and Bézier control + * points may overshoot it, which is what gives the blob its bulge.

+ * + * @author Artem Demchyshyn + */ +public final class VectorPathExample { + + private static final DocumentColor INK = DocumentColor.rgb(20, 60, 120); + private static final DocumentColor SAND = DocumentColor.rgb(235, 205, 160); + private static final DocumentColor SAND_EDGE = DocumentColor.rgb(140, 90, 30); + private static final DocumentColor MOSS = DocumentColor.rgb(208, 226, 213); + private static final DocumentColor MOSS_EDGE = DocumentColor.rgb(60, 110, 80); + + private VectorPathExample() { + } + + /** + * Renders the three-shape sheet: a stroked Bézier wave, a filled blob + * with overshooting control points, and a mixed line/curve ribbon. + * + * @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", "vector-path.pdf"); + + try (DocumentSession document = GraphCompose.document(pdfFile) + .pageSize(420, 420) + .margin(DocumentInsets.of(28)) + .create()) { + document.pageFlow(page -> page + .addParagraph("Stroked Bézier wave — two cubic spans, zero tessellation") + .addPath(path -> path + .name("Wave") + .size(364, 64) + .moveTo(0.0, 0.5) + .curveTo(0.25, 1.1, 0.25, -0.1, 0.5, 0.5) + .curveTo(0.75, 1.1, 0.75, -0.1, 1.0, 0.5) + .stroke(DocumentStroke.of(INK, 2.4)) + .margin(DocumentInsets.bottom(16))) + .addParagraph("Filled blob — closed curves, control points overshoot the box") + .addPath(path -> path + .name("Blob") + .size(120, 104) + .moveTo(0.5, 1.0) + .curveTo(1.12, 0.94, 0.96, 0.08, 0.5, 0.0) + .curveTo(0.04, 0.08, -0.12, 0.94, 0.5, 1.0) + .closePath() + .fillColor(SAND) + .stroke(DocumentStroke.of(SAND_EDGE, 1.4)) + .margin(DocumentInsets.bottom(16))) + .addParagraph("Mixed ribbon — lines and curves in one closed, filled subpath") + .addPath(path -> path + .name("Ribbon") + .size(364, 70) + .moveTo(0.0, 0.85) + .lineTo(0.18, 0.85) + .curveTo(0.42, 1.05, 0.58, 0.55, 0.82, 0.75) + .lineTo(1.0, 0.75) + .lineTo(1.0, 0.15) + .curveTo(0.6, -0.05, 0.4, 0.45, 0.0, 0.25) + .closePath() + .fillColor(MOSS) + .stroke(DocumentStroke.of(MOSS_EDGE, 1.2)))); + + document.buildPdf(); + } + + return pdfFile; + } + + public static void main(String[] args) throws Exception { + System.out.println("Generated: " + generate()); + } +} diff --git a/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java b/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java index 45f2c60cf..77447fa0d 100644 --- a/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java +++ b/examples/src/main/java/com/demcha/examples/support/ShowcaseMetadata.java @@ -95,6 +95,7 @@ record Entry(String title, String description, List tags, String codeUrl feature("tables", "composed-table-cell-showcase", "Composed Table Cells", "DocumentTableCell.node(DocumentNode) — paragraphs, lists, sub-tables inside cells with two-pass measurement.", "tables", "v1.6"); feature("canvas", "canvas-layer-showcase", "Canvas Layer (free-canvas)", "CanvasLayerNode — pixel-precise (x,y) placement of children inside a fixed bounding box.", "canvas", "v1.6", "absolute"); feature("shapes", "shape-container", "Shape-as-Container", "Rounded rect, ellipse, circle containers with ClipPolicy and layered children.", "shapes", "clip"); + feature("shapes", "vector-path", "Vector Paths (Bézier)", "addPath(...) — free-form design shapes with native cubic Bézier curves: stroked waves, filled blobs, mixed line/curve ribbons. No tessellation.", "shapes", "bezier", "v1.8"); feature("transforms", "transforms", "Layers + Transforms", "rotate / scale on every leaf builder + LayerStack with explicit z-index.", "transforms", "layers"); feature("text", "rich-text-showcase", "Rich Text", "Inline runs with bold / italic / colour / link options, markdown parsing.", "text", "rich"); feature("text", "section-presets", "Section Presets", "Pre-baked section bands, accent strips, soft panels for templates.", "text", "sections"); diff --git a/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java b/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java index bf4fdb414..0bbeaf123 100644 --- a/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java @@ -531,6 +531,18 @@ public T addEllipse(Consumer spec) { return add(BuilderSupport.configure(new EllipseBuilder(), spec).build()); } + /** + * Adds a free-form vector path configured through a nested builder — + * design shapes with native cubic Bézier curves. + * + * @param spec path builder callback + * @return this builder + * @since 1.8.0 + */ + public T addPath(Consumer spec) { + return add(BuilderSupport.configure(new PathBuilder(), spec).build()); + } + /** * Adds a filled circle ellipse — shortcut for * {@code addEllipse(e -> e.circle(diameter).fillColor(fillColor))}. diff --git a/src/main/java/com/demcha/compose/document/dsl/PathBuilder.java b/src/main/java/com/demcha/compose/document/dsl/PathBuilder.java new file mode 100644 index 000000000..74b6a20fb --- /dev/null +++ b/src/main/java/com/demcha/compose/document/dsl/PathBuilder.java @@ -0,0 +1,218 @@ +package com.demcha.compose.document.dsl; + +import com.demcha.compose.document.node.PathNode; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentPathSegment; +import com.demcha.compose.document.style.DocumentStroke; + +import java.awt.*; +import java.util.ArrayList; +import java.util.List; + +/** + * Builder for semantic vector-path nodes — free-form design shapes with + * native cubic Bézier curves. + * + *

Segments use normalized unit-box coordinates in the PDF orientation + * ({@code (0, 0)} = bottom-left, {@code y} grows upward) and are scaled to + * the node's {@code width × height} at render time; Bézier control points + * may overshoot the box. Start with {@link #moveTo}, then chain + * {@link #lineTo} / {@link #curveTo}, and optionally {@link #closePath()} + * before {@link #build()}:

+ * + *
{@code
+ * flow.addPath(path -> path
+ *         .name("Wave")
+ *         .size(320, 60)
+ *         .moveTo(0.0, 0.5)
+ *         .curveTo(0.25, 1.0, 0.25, 0.0, 0.5, 0.5)
+ *         .curveTo(0.75, 1.0, 0.75, 0.0, 1.0, 0.5)
+ *         .stroke(DocumentStroke.of(accent, 2)));
+ * }
+ * + * @author Artem Demchyshyn + * @since 1.8.0 + */ +public final class PathBuilder { + private final List segments = new ArrayList<>(); + private String name = ""; + private double width; + private double height; + private DocumentColor fillColor; + private DocumentStroke stroke; + private DocumentInsets padding = DocumentInsets.zero(); + private DocumentInsets margin = DocumentInsets.zero(); + + /** + * Creates a path builder. + */ + public PathBuilder() { + } + + /** + * Sets the path node name. + * + * @param name name used in snapshots and layout graph paths + * @return this builder + */ + public PathBuilder name(String name) { + this.name = name == null ? "" : name; + return this; + } + + /** + * Sets the path box width. + * + * @param width width in points + * @return this builder + */ + public PathBuilder width(double width) { + this.width = width; + return this; + } + + /** + * Sets the path box height. + * + * @param height height in points + * @return this builder + */ + public PathBuilder height(double height) { + this.height = height; + return this; + } + + /** + * Sets the path box width and height. + * + * @param width width in points + * @param height height in points + * @return this builder + */ + public PathBuilder size(double width, double height) { + this.width = width; + this.height = height; + return this; + } + + /** + * Starts a new subpath at the given normalized point. + * + * @param x normalized horizontal position (0 = left edge, 1 = right edge) + * @param y normalized vertical position (0 = bottom edge, 1 = top edge) + * @return this builder + */ + public PathBuilder moveTo(double x, double y) { + segments.add(DocumentPathSegment.moveTo(x, y)); + return this; + } + + /** + * Draws a straight line from the current point. + * + * @param x normalized horizontal target + * @param y normalized vertical target + * @return this builder + */ + public PathBuilder lineTo(double x, double y) { + segments.add(DocumentPathSegment.lineTo(x, y)); + return this; + } + + /** + * Draws a cubic Bézier curve from the current point. Control points may + * overshoot the unit box. + * + * @param control1X first control point, horizontal + * @param control1Y first control point, vertical + * @param control2X second control point, horizontal + * @param control2Y second control point, vertical + * @param x end point, horizontal + * @param y end point, vertical + * @return this builder + */ + public PathBuilder curveTo(double control1X, double control1Y, + double control2X, double control2Y, + double x, double y) { + segments.add(DocumentPathSegment.cubicTo(control1X, control1Y, control2X, control2Y, x, y)); + return this; + } + + /** + * Closes the current subpath back to its last {@link #moveTo}. + * + * @return this builder + */ + public PathBuilder closePath() { + segments.add(DocumentPathSegment.close()); + return this; + } + + /** + * Sets the fill color (non-zero winding rule). + * + * @param fillColor fill color + * @return this builder + */ + public PathBuilder fillColor(DocumentColor fillColor) { + this.fillColor = fillColor; + return this; + } + + /** + * Sets the fill color (non-zero winding rule). + * + * @param fillColor fill color + * @return this builder + */ + public PathBuilder fillColor(Color fillColor) { + this.fillColor = fillColor == null ? null : DocumentColor.of(fillColor); + return this; + } + + /** + * Sets the outline stroke. + * + * @param stroke stroke descriptor, or {@code null} for no stroke + * @return this builder + */ + public PathBuilder stroke(DocumentStroke stroke) { + this.stroke = stroke; + return this; + } + + /** + * Sets the path padding. + * + * @param padding padding in points + * @return this builder + */ + public PathBuilder padding(DocumentInsets padding) { + this.padding = padding == null ? DocumentInsets.zero() : padding; + return this; + } + + /** + * Sets the path margin. + * + * @param margin margin in points + * @return this builder + */ + public PathBuilder margin(DocumentInsets margin) { + this.margin = margin == null ? DocumentInsets.zero() : margin; + return this; + } + + /** + * Builds the path node. + * + * @return path node + * @throws IllegalArgumentException if the segments do not start with a + * move-to, fewer than two segments were + * added, or the box is not positive + */ + public PathNode build() { + return new PathNode(name, width, height, segments, fillColor, stroke, padding, margin); + } +} diff --git a/src/test/java/com/demcha/compose/document/dsl/PathBuilderTest.java b/src/test/java/com/demcha/compose/document/dsl/PathBuilderTest.java new file mode 100644 index 000000000..5584bd66f --- /dev/null +++ b/src/test/java/com/demcha/compose/document/dsl/PathBuilderTest.java @@ -0,0 +1,87 @@ +package com.demcha.compose.document.dsl; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.layout.LayoutGraph; +import com.demcha.compose.document.layout.PlacedNode; +import com.demcha.compose.document.node.PathNode; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentPathSegment; +import com.demcha.compose.document.style.DocumentStroke; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * DSL coverage for {@link PathBuilder}: fluent assembly into a + * {@link PathNode}, node validation flowing through {@code build()}, and + * end-to-end placement through {@code addPath(...)} on a page flow. + */ +class PathBuilderTest { + + @Test + void builderAssemblesTheNode() { + PathNode node = new PathBuilder() + .name("Swoosh") + .size(200, 80) + .moveTo(0.0, 0.5) + .curveTo(0.3, 1.1, 0.7, -0.1, 1.0, 0.5) + .lineTo(1.0, 0.0) + .closePath() + .fillColor(DocumentColor.rgb(230, 240, 250)) + .stroke(DocumentStroke.of(DocumentColor.rgb(20, 60, 120), 1.5)) + .padding(DocumentInsets.of(2)) + .margin(DocumentInsets.bottom(6)) + .build(); + + assertThat(node.name()).isEqualTo("Swoosh"); + assertThat(node.width()).isEqualTo(200); + assertThat(node.height()).isEqualTo(80); + assertThat(node.segments()).hasSize(4); + assertThat(node.segments().get(0)).isInstanceOf(DocumentPathSegment.MoveTo.class); + assertThat(node.segments().get(1)).isInstanceOf(DocumentPathSegment.CubicTo.class); + assertThat(node.segments().get(3)).isInstanceOf(DocumentPathSegment.Close.class); + assertThat(node.fillColor()).isNotNull(); + assertThat(node.stroke()).isNotNull(); + assertThat(node.padding().top()).isEqualTo(2); + assertThat(node.margin().bottom()).isEqualTo(6); + } + + @Test + void nodeValidationFlowsThroughBuild() { + PathBuilder missingMoveTo = new PathBuilder() + .size(100, 40) + .lineTo(0.5, 0.5) + .lineTo(1.0, 1.0); + + assertThatThrownBy(missingMoveTo::build) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must start with a MoveTo"); + } + + @Test + void addPathPlacesTheNodeInTheFlow() throws Exception { + try (DocumentSession document = GraphCompose.document() + .pageSize(240, 200) + .margin(DocumentInsets.of(12)) + .create()) { + document.pageFlow(page -> page.addPath(path -> path + .name("Wavy") + .size(200, 60) + .moveTo(0.0, 0.5) + .curveTo(0.25, 1.0, 0.75, 0.0, 1.0, 0.5) + .stroke(DocumentStroke.of(DocumentColor.rgb(20, 60, 120), 2.0)))); + + LayoutGraph graph = document.layoutGraph(); + assertThat(graph.nodes()).extracting(PlacedNode::path) + .anyMatch(path -> path.endsWith("Wavy[0]")); + + byte[] pdf = document.toPdfBytes(); + assertThat(new String(pdf, 0, 5, StandardCharsets.US_ASCII)).isEqualTo("%PDF-"); + } + } +}