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-");
+ }
+ }
+}