From 64fafef3b2750252e8e29931f49d60aa247ac320 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 12 Jun 2026 01:30:21 +0100 Subject: [PATCH] feat(engine): PathNode vector-path primitive with native PDF curve operators W1 of the path workstream: general-purpose PathNode (normalized DocumentPathSegment moveTo/lineTo/cubicTo/close, fill via non-zero winding and/or stroke, atomic pagination, deterministic snapshots) mirroring the PolygonNode stack one-to-one - node + PathDefinition + PathFragmentPayload + PdfPathFragmentRenderHandler emitting native moveTo/curveTo, registered in BuiltInNodeDefinitions and defaultHandlers (map dispatch, no chain edits). Control points deliberately may overshoot the unit box (Catmull-Rom-to-Bezier conversions need it; ShapePoint clamping is why segments carry raw finite doubles). Groundwork for smooth chart lines (W3) and SVG path import. 9 tests: unit validation, structural+snapshot baseline, visual demo (wave/blob/ribbon). Full gate: 1254 tests, BUILD SUCCESS. --- CHANGELOG.md | 9 ++ .../fixed/pdf/PdfFixedLayoutBackend.java | 1 + .../PdfPathFragmentRenderHandler.java | 46 ++++++ .../fixed/pdf/handlers/PdfShapeGeometry.java | 30 ++++ .../layout/BuiltInNodeDefinitions.java | 1 + .../layout/definitions/PathDefinition.java | 69 ++++++++ .../layout/payloads/PathFragmentPayload.java | 40 +++++ .../compose/document/node/PathNode.java | 76 +++++++++ .../document/style/DocumentPathSegment.java | 150 ++++++++++++++++++ .../document/api/PathNodeRenderingTest.java | 69 ++++++++ .../compose/document/node/PathNodeTest.java | 94 +++++++++++ .../testing/visual/PathPrimitiveDemoTest.java | 105 ++++++++++++ .../document/path_primitive.json | 77 +++++++++ 13 files changed, 767 insertions(+) create mode 100644 src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPathFragmentRenderHandler.java create mode 100644 src/main/java/com/demcha/compose/document/layout/definitions/PathDefinition.java create mode 100644 src/main/java/com/demcha/compose/document/layout/payloads/PathFragmentPayload.java create mode 100644 src/main/java/com/demcha/compose/document/node/PathNode.java create mode 100644 src/main/java/com/demcha/compose/document/style/DocumentPathSegment.java create mode 100644 src/test/java/com/demcha/compose/document/api/PathNodeRenderingTest.java create mode 100644 src/test/java/com/demcha/compose/document/node/PathNodeTest.java create mode 100644 src/test/java/com/demcha/testing/visual/PathPrimitiveDemoTest.java create mode 100644 src/test/resources/layout-snapshots/document/path_primitive.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 75e300a3..b929ccf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,15 @@ Entries land here as they merge. into the new general-purpose `PolygonNode` (arc-tessellated ring polygons at a fixed 3° step — deterministic vertices, no new render handlers), which also lays the groundwork for SVG icon-path import. +- **Vector path primitive** (`@since 1.8.0`). New `PathNode` — the open-path, + curve-capable sibling of `PolygonNode`: normalized `DocumentPathSegment`s + (`moveTo` / `lineTo` / cubic `cubicTo` / `close`; Bézier control points are + free to overshoot the unit box) are scaled to the node's box and rendered + with native PDF curve operators, so curves stay perfectly smooth at any + 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. - **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/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java index 6d2d7925..f911c153 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/PdfFixedLayoutBackend.java @@ -96,6 +96,7 @@ private static List> defaultHandlers() { new PdfLineFragmentRenderHandler(), new PdfEllipseFragmentRenderHandler(), new PdfPolygonFragmentRenderHandler(), + new PdfPathFragmentRenderHandler(), new PdfImageFragmentRenderHandler(), new PdfTableRowFragmentRenderHandler(), new PdfShapeClipBeginRenderHandler(), diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPathFragmentRenderHandler.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPathFragmentRenderHandler.java new file mode 100644 index 00000000..080d8509 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPathFragmentRenderHandler.java @@ -0,0 +1,46 @@ +package com.demcha.compose.document.backend.fixed.pdf.handlers; + +import com.demcha.compose.document.backend.fixed.pdf.PdfFragmentRenderHandler; +import com.demcha.compose.document.backend.fixed.pdf.PdfRenderEnvironment; +import com.demcha.compose.document.layout.PlacedFragment; +import com.demcha.compose.document.layout.payloads.PathFragmentPayload; +import org.apache.pdfbox.pdmodel.PDPageContentStream; + +import java.io.IOException; + +/** + * Renders fixed vector-path fragments with native PDF line and cubic-Bézier + * operators — curves stay smooth at any zoom level. + * + * @author Artem Demchyshyn + */ +public final class PdfPathFragmentRenderHandler + implements PdfFragmentRenderHandler { + + /** + * Creates the path fragment renderer. + */ + public PdfPathFragmentRenderHandler() { + } + + @Override + public Class payloadType() { + return PathFragmentPayload.class; + } + + @Override + public void render(PlacedFragment fragment, + PathFragmentPayload payload, + PdfRenderEnvironment environment) throws IOException { + if (fragment.width() <= 0 || fragment.height() <= 0) { + return; + } + PDPageContentStream stream = environment.pageSurface(fragment.pageIndex()); + float x = (float) fragment.x(); + float y = (float) fragment.y(); + float width = (float) fragment.width(); + float height = (float) fragment.height(); + PdfShapeGeometry.fillAndStrokePath(stream, payload.fillColor(), payload.stroke(), + s -> PdfShapeGeometry.addPathSegments(s, x, y, width, height, payload.segments())); + } +} diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeGeometry.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeGeometry.java index a514734b..9abfa9f9 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeGeometry.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfShapeGeometry.java @@ -1,5 +1,6 @@ package com.demcha.compose.document.backend.fixed.pdf.handlers; +import com.demcha.compose.document.style.DocumentPathSegment; import com.demcha.compose.document.style.ShapePoint; import com.demcha.compose.engine.components.content.shape.Stroke; import org.apache.pdfbox.pdmodel.PDPageContentStream; @@ -79,6 +80,35 @@ static void addPolygonPath(PDPageContentStream stream, stream.closePath(); } + /** + * Appends a normalized segment path scaled to the fragment box, emitting + * native line and cubic-Bézier operators. Segments follow the + * {@link DocumentPathSegment} contract: normalized unit-box coordinates, + * PDF y-up orientation, {@code MoveTo} first, control points free to + * overshoot the box. + */ + static void addPathSegments(PDPageContentStream stream, + float x, + float y, + float width, + float height, + List segments) throws IOException { + for (DocumentPathSegment segment : segments) { + if (segment instanceof DocumentPathSegment.MoveTo move) { + stream.moveTo(x + (float) (move.x() * width), y + (float) (move.y() * height)); + } else if (segment instanceof DocumentPathSegment.LineTo line) { + stream.lineTo(x + (float) (line.x() * width), y + (float) (line.y() * height)); + } else if (segment instanceof DocumentPathSegment.CubicTo cubic) { + stream.curveTo( + x + (float) (cubic.control1X() * width), y + (float) (cubic.control1Y() * height), + x + (float) (cubic.control2X() * width), y + (float) (cubic.control2Y() * height), + x + (float) (cubic.x() * width), y + (float) (cubic.y() * height)); + } else if (segment instanceof DocumentPathSegment.Close) { + stream.closePath(); + } + } + } + /** * Appends a closed rounded-rectangle path whose four corners may have * independent radii. Each radius gets its own Bezier arc; a zero radius diff --git a/src/main/java/com/demcha/compose/document/layout/BuiltInNodeDefinitions.java b/src/main/java/com/demcha/compose/document/layout/BuiltInNodeDefinitions.java index 8a909495..f1a8ac47 100644 --- a/src/main/java/com/demcha/compose/document/layout/BuiltInNodeDefinitions.java +++ b/src/main/java/com/demcha/compose/document/layout/BuiltInNodeDefinitions.java @@ -43,6 +43,7 @@ public static NodeRegistry registerDefaults(NodeRegistry registry) { .register(new TableDefinition()) .register(new CanvasLayerDefinition()) .register(new PolygonDefinition()) + .register(new PathDefinition()) .register(new ChartDefinition()); } } diff --git a/src/main/java/com/demcha/compose/document/layout/definitions/PathDefinition.java b/src/main/java/com/demcha/compose/document/layout/definitions/PathDefinition.java new file mode 100644 index 00000000..bad2c5d0 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/layout/definitions/PathDefinition.java @@ -0,0 +1,69 @@ +package com.demcha.compose.document.layout.definitions; + +import com.demcha.compose.document.layout.*; +import com.demcha.compose.document.layout.payloads.PathFragmentPayload; +import com.demcha.compose.document.node.PathNode; + +import java.util.List; + +import static com.demcha.compose.document.layout.NodeDefinitionSupport.EPS; +import static com.demcha.compose.document.layout.NodeDefinitionSupport.toStroke; + +/** + * Layout definition for {@link PathNode}: a fixed-size atomic vector-path + * fragment rendered through the path fragment pipeline with native curve + * operators. + * + * @author Artem Demchyshyn + * @since 1.8.0 + */ +public final class PathDefinition implements NodeDefinition { + + /** + * Creates the path layout definition. + */ + public PathDefinition() { + } + + @Override + public Class nodeType() { + return PathNode.class; + } + + @Override + public PreparedNode prepare(PathNode node, PrepareContext ctx, BoxConstraints constraints) { + return PreparedNode.leaf(node, new MeasureResult( + node.width() + node.padding().horizontal(), + node.height() + node.padding().vertical())); + } + + @Override + public PaginationPolicy paginationPolicy(PathNode node) { + return PaginationPolicy.ATOMIC; + } + + @Override + public List emitFragments(PreparedNode prepared, + FragmentContext ctx, + FragmentPlacement placement) { + PathNode node = prepared.node(); + double width = Math.max(0.0, placement.width() - node.padding().horizontal()); + double height = Math.max(0.0, placement.height() - node.padding().vertical()); + if (width <= EPS || height <= EPS) { + return List.of(); + } + return List.of(new LayoutFragment( + placement.path(), + 0, + node.padding().left(), + node.padding().bottom(), + width, + height, + new PathFragmentPayload( + node.segments(), + node.fillColor() == null ? null : node.fillColor().color(), + toStroke(node.stroke()), + null, + null))); + } +} diff --git a/src/main/java/com/demcha/compose/document/layout/payloads/PathFragmentPayload.java b/src/main/java/com/demcha/compose/document/layout/payloads/PathFragmentPayload.java new file mode 100644 index 00000000..6ad7617e --- /dev/null +++ b/src/main/java/com/demcha/compose/document/layout/payloads/PathFragmentPayload.java @@ -0,0 +1,40 @@ +package com.demcha.compose.document.layout.payloads; + +import com.demcha.compose.document.node.DocumentBookmarkOptions; +import com.demcha.compose.document.node.DocumentLinkOptions; +import com.demcha.compose.document.style.DocumentPathSegment; +import com.demcha.compose.engine.components.content.shape.Stroke; + +import java.awt.*; +import java.util.List; +import java.util.Objects; + +/** + * PDF payload for a resolved vector-path fragment (curved chart strokes, + * decorative design shapes, imported icon paths). The normalized segments are + * scaled to the placed fragment's size by the render handler, which emits + * native PDF line and cubic-Bézier operators. + * + * @param segments normalized path segments, starting with a move-to + * @param fillColor optional fill color (non-zero winding rule) + * @param stroke optional stroke + * @param linkOptions optional fragment-level link metadata + * @param bookmarkOptions optional fragment-level bookmark metadata + * @author Artem Demchyshyn + * @since 1.8.0 + */ +public record PathFragmentPayload( + List segments, + Color fillColor, + Stroke stroke, + DocumentLinkOptions linkOptions, + DocumentBookmarkOptions bookmarkOptions +) implements PdfSemanticFragmentPayload { + /** + * Copies the segment list defensively. + */ + public PathFragmentPayload { + Objects.requireNonNull(segments, "segments"); + segments = List.copyOf(segments); + } +} diff --git a/src/main/java/com/demcha/compose/document/node/PathNode.java b/src/main/java/com/demcha/compose/document/node/PathNode.java new file mode 100644 index 00000000..57db84f6 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/node/PathNode.java @@ -0,0 +1,76 @@ +package com.demcha.compose.document.node; + +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.util.List; +import java.util.Objects; + +/** + * Atomic filled/stroked vector path inside a fixed-size box. Segments are + * normalized {@link DocumentPathSegment}s scaled to the node's + * {@code width × height} at render time — the open-path, curve-capable + * sibling of {@link PolygonNode}. + * + *

This is the leaf vehicle for arbitrary vector geometry with real cubic + * Bézier curves: smooth chart lines compile into it, decorative design + * shapes can be authored against it, and imported SVG paths land here + * tomorrow. The PDF backend emits native {@code curveTo} operators, so + * curves stay perfectly smooth at any zoom level instead of being + * tessellated into straight pieces.

+ * + * @param name node name used in snapshots and layout graph paths + * @param width resolved box width + * @param height resolved box height + * @param segments normalized path segments; must start with a + * {@link DocumentPathSegment.MoveTo} + * @param fillColor optional fill colour (non-zero winding rule) + * @param stroke optional outline stroke + * @param padding inner padding + * @param margin outer margin + * @author Artem Demchyshyn + * @since 1.8.0 + */ +public record PathNode( + String name, + double width, + double height, + List segments, + DocumentColor fillColor, + DocumentStroke stroke, + DocumentInsets padding, + DocumentInsets margin +) implements DocumentNode { + /** + * Validates dimensions and the segment list; copy-protects the segments. + */ + public PathNode { + name = name == null ? "" : name; + Objects.requireNonNull(segments, "segments"); + segments = List.copyOf(segments); + if (segments.size() < 2) { + throw new IllegalArgumentException( + "path needs at least a MoveTo and one drawing segment: " + segments.size()); + } + if (!(segments.get(0) instanceof DocumentPathSegment.MoveTo)) { + throw new IllegalArgumentException( + "path must start with a MoveTo segment, found: " + + segments.get(0).getClass().getSimpleName()); + } + padding = padding == null ? DocumentInsets.zero() : padding; + margin = margin == null ? DocumentInsets.zero() : margin; + if (width <= 0 || Double.isNaN(width) || Double.isInfinite(width)) { + throw new IllegalArgumentException("width must be finite and positive: " + width); + } + if (height <= 0 || Double.isNaN(height) || Double.isInfinite(height)) { + throw new IllegalArgumentException("height must be finite and positive: " + height); + } + } + + @Override + public String nodeKind() { + return "Path"; + } +} diff --git a/src/main/java/com/demcha/compose/document/style/DocumentPathSegment.java b/src/main/java/com/demcha/compose/document/style/DocumentPathSegment.java new file mode 100644 index 00000000..71d126c1 --- /dev/null +++ b/src/main/java/com/demcha/compose/document/style/DocumentPathSegment.java @@ -0,0 +1,150 @@ +package com.demcha.compose.document.style; + +/** + * One segment of a {@link com.demcha.compose.document.node.PathNode} outline. + * + *

Coordinates are normalized to the node's unit box and follow the same + * orientation as {@link ShapePoint}: {@code (0, 0)} is the bottom-left corner + * and {@code y} grows upward (the PDF convention). The node scales them to + * its resolved {@code width × height} at render time. Unlike + * {@link ShapePoint}, values are not clamped to {@code [0, 1]} — + * Bézier control points legitimately overshoot the box, and end points may + * too; geometry outside the box simply draws outside the node's bounds.

+ * + *

A path begins with {@link MoveTo}; {@link CubicTo} spans a cubic Bézier + * curve with two control points; {@link Close} closes the current subpath + * back to its last {@code MoveTo}. Multiple subpaths (further {@code MoveTo} + * segments) are allowed. Filling uses the PDF non-zero winding rule, and an + * unclosed subpath is closed implicitly by the fill while a stroke leaves it + * open.

+ * + * @author Artem Demchyshyn + * @since 1.8.0 + */ +public sealed interface DocumentPathSegment + permits DocumentPathSegment.MoveTo, DocumentPathSegment.LineTo, + DocumentPathSegment.CubicTo, DocumentPathSegment.Close { + + /** + * 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 a {@code MoveTo} segment + */ + static MoveTo moveTo(double x, double y) { + return new MoveTo(x, y); + } + + /** + * Draws a straight line from the current point. + * + * @param x normalized horizontal target + * @param y normalized vertical target + * @return a {@code LineTo} segment + */ + static LineTo lineTo(double x, double y) { + return new LineTo(x, y); + } + + /** + * Draws a cubic Bézier curve from the current point. + * + * @param control1X normalized horizontal position of the first control point + * @param control1Y normalized vertical position of the first control point + * @param control2X normalized horizontal position of the second control point + * @param control2Y normalized vertical position of the second control point + * @param x normalized horizontal end point + * @param y normalized vertical end point + * @return a {@code CubicTo} segment + */ + static CubicTo cubicTo(double control1X, double control1Y, + double control2X, double control2Y, + double x, double y) { + return new CubicTo(control1X, control1Y, control2X, control2Y, x, y); + } + + /** + * Closes the current subpath back to its last {@code MoveTo}. + * + * @return a {@code Close} segment + */ + static Close close() { + return new Close(); + } + + /** + * Starts a new subpath. + * + * @param x normalized horizontal position + * @param y normalized vertical position + * @since 1.8.0 + */ + record MoveTo(double x, double y) implements DocumentPathSegment { + /** + * Validates that both coordinates are finite. + */ + public MoveTo { + requireFinite("x", x); + requireFinite("y", y); + } + } + + /** + * Straight line to the given point. + * + * @param x normalized horizontal target + * @param y normalized vertical target + * @since 1.8.0 + */ + record LineTo(double x, double y) implements DocumentPathSegment { + /** + * Validates that both coordinates are finite. + */ + public LineTo { + requireFinite("x", x); + requireFinite("y", y); + } + } + + /** + * Cubic Bézier curve to the given end point. + * + * @param control1X normalized first control point, horizontal + * @param control1Y normalized first control point, vertical + * @param control2X normalized second control point, horizontal + * @param control2Y normalized second control point, vertical + * @param x normalized end point, horizontal + * @param y normalized end point, vertical + * @since 1.8.0 + */ + record CubicTo(double control1X, double control1Y, + double control2X, double control2Y, + double x, double y) implements DocumentPathSegment { + /** + * Validates that every coordinate is finite. + */ + public CubicTo { + requireFinite("control1X", control1X); + requireFinite("control1Y", control1Y); + requireFinite("control2X", control2X); + requireFinite("control2Y", control2Y); + requireFinite("x", x); + requireFinite("y", y); + } + } + + /** + * Closes the current subpath. + * + * @since 1.8.0 + */ + record Close() implements DocumentPathSegment { + } + + private static void requireFinite(String label, double value) { + if (Double.isNaN(value) || Double.isInfinite(value)) { + throw new IllegalArgumentException(label + " must be finite: " + value); + } + } +} diff --git a/src/test/java/com/demcha/compose/document/api/PathNodeRenderingTest.java b/src/test/java/com/demcha/compose/document/api/PathNodeRenderingTest.java new file mode 100644 index 00000000..a1406502 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/api/PathNodeRenderingTest.java @@ -0,0 +1,69 @@ +package com.demcha.compose.document.api; + +import com.demcha.compose.GraphCompose; +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.DocumentStroke; +import com.demcha.compose.testing.layout.LayoutSnapshotAssertions; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static com.demcha.compose.document.style.DocumentPathSegment.close; +import static com.demcha.compose.document.style.DocumentPathSegment.cubicTo; +import static com.demcha.compose.document.style.DocumentPathSegment.moveTo; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end coverage for the {@link PathNode} primitive: atomic placement + * with stable semantic paths, deterministic layout snapshot, and a valid PDF + * render through the native curve-operator handler. + */ +class PathNodeRenderingTest { + + @Test + void pathNodesPlaceAtomicallyAndSnapshotDeterministically() throws Exception { + try (DocumentSession session = GraphCompose.document() + .pageSize(240, 200) + .margin(DocumentInsets.of(12)) + .create()) { + session.add(wave()); + session.add(blob()); + + LayoutGraph graph = session.layoutGraph(); + assertThat(graph.totalPages()).isEqualTo(1); + assertThat(graph.nodes()).extracting(PlacedNode::path) + .contains("Wave[0]", "Blob[1]"); + + LayoutSnapshotAssertions.assertMatches(session, "document/path_primitive"); + + byte[] pdf = session.toPdfBytes(); + assertThat(new String(pdf, 0, 5, StandardCharsets.US_ASCII)).isEqualTo("%PDF-"); + } + } + + private static PathNode wave() { + return new PathNode("Wave", 200, 70, + List.of(moveTo(0.0, 0.5), + cubicTo(0.25, 1.0, 0.25, 0.0, 0.5, 0.5), + cubicTo(0.75, 1.0, 0.75, 0.0, 1.0, 0.5)), + null, + DocumentStroke.of(DocumentColor.rgb(20, 60, 120), 2.0), + DocumentInsets.zero(), DocumentInsets.bottom(8)); + } + + private static PathNode blob() { + return new PathNode("Blob", 90, 80, + List.of(moveTo(0.5, 1.0), + cubicTo(1.1, 0.95, 0.95, 0.1, 0.5, 0.0), + cubicTo(0.05, 0.1, -0.1, 0.95, 0.5, 1.0), + close()), + DocumentColor.rgb(235, 205, 160), + DocumentStroke.of(DocumentColor.rgb(140, 90, 30), 1.2), + DocumentInsets.zero(), DocumentInsets.zero()); + } +} diff --git a/src/test/java/com/demcha/compose/document/node/PathNodeTest.java b/src/test/java/com/demcha/compose/document/node/PathNodeTest.java new file mode 100644 index 00000000..e27fa6be --- /dev/null +++ b/src/test/java/com/demcha/compose/document/node/PathNodeTest.java @@ -0,0 +1,94 @@ +package com.demcha.compose.document.node; + +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.util.ArrayList; +import java.util.List; + +import static com.demcha.compose.document.style.DocumentPathSegment.close; +import static com.demcha.compose.document.style.DocumentPathSegment.cubicTo; +import static com.demcha.compose.document.style.DocumentPathSegment.lineTo; +import static com.demcha.compose.document.style.DocumentPathSegment.moveTo; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Value semantics of {@link PathNode} and {@link DocumentPathSegment}: + * segment-order validation, finite-coordinate checks, defensive copying, + * and the deliberate freedom of Bézier control points to overshoot the + * unit box. + */ +class PathNodeTest { + + private static final DocumentStroke STROKE = DocumentStroke.of(DocumentColor.rgb(20, 60, 120), 1.5); + + @Test + void pathMustStartWithAMoveTo() { + assertThatThrownBy(() -> node(List.of(lineTo(0.2, 0.2), lineTo(0.8, 0.8)))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must start with a MoveTo"); + } + + @Test + void pathNeedsAtLeastOneDrawingSegment() { + assertThatThrownBy(() -> node(List.of(moveTo(0.1, 0.1)))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("at least a MoveTo and one drawing segment"); + } + + @Test + void coordinatesMustBeFinite() { + assertThatThrownBy(() -> cubicTo(Double.NaN, 0, 0, 0, 1, 1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must be finite"); + assertThatThrownBy(() -> moveTo(Double.POSITIVE_INFINITY, 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must be finite"); + } + + @Test + void controlPointsMayOvershootTheUnitBox() { + // Catmull-Rom→Bézier conversions and expressive design curves push + // control points outside [0, 1]; the segment type must allow it. + PathNode node = node(List.of( + moveTo(0.5, 1.0), + cubicTo(1.15, 0.9, 0.95, -0.1, 0.5, 0.0), + close())); + + assertThat(node.segments()).hasSize(3); + } + + @Test + void segmentListIsCopyProtectedAndImmutable() { + List source = new ArrayList<>(List.of(moveTo(0, 0), lineTo(1, 1))); + PathNode node = node(source); + source.add(close()); + + assertThat(node.segments()).hasSize(2); + assertThatThrownBy(() -> node.segments().add(close())) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void boxDimensionsMustBeFiniteAndPositive() { + assertThatThrownBy(() -> new PathNode("Bad", 0, 40, + List.of(moveTo(0, 0), lineTo(1, 1)), null, STROKE, + DocumentInsets.zero(), DocumentInsets.zero())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("width must be finite and positive"); + } + + @Test + void nodeKindIsPath() { + assertThat(node(List.of(moveTo(0, 0), lineTo(1, 1))).nodeKind()).isEqualTo("Path"); + } + + private static PathNode node(List segments) { + return new PathNode("P", 120, 60, segments, null, STROKE, + DocumentInsets.zero(), DocumentInsets.zero()); + } +} diff --git a/src/test/java/com/demcha/testing/visual/PathPrimitiveDemoTest.java b/src/test/java/com/demcha/testing/visual/PathPrimitiveDemoTest.java new file mode 100644 index 00000000..2f77f356 --- /dev/null +++ b/src/test/java/com/demcha/testing/visual/PathPrimitiveDemoTest.java @@ -0,0 +1,105 @@ +package com.demcha.testing.visual; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +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.DocumentStroke; +import com.demcha.compose.testing.visual.ImageDiff; +import com.demcha.compose.testing.visual.PdfVisualRegression; +import org.junit.jupiter.api.Test; + +import java.awt.image.BufferedImage; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static com.demcha.compose.document.style.DocumentPathSegment.close; +import static com.demcha.compose.document.style.DocumentPathSegment.cubicTo; +import static com.demcha.compose.document.style.DocumentPathSegment.lineTo; +import static com.demcha.compose.document.style.DocumentPathSegment.moveTo; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Renders the path primitive's range — a stroked Bézier wave, a filled blob + * with overshooting control points, and a mixed line/curve ribbon closed for + * filling — and writes a human-review PDF. Asserts the curves visibly paint + * over a blank page. + */ +class PathPrimitiveDemoTest { + + private static final PdfVisualRegression VISUAL = PdfVisualRegression.standard(); + + @Test + void pathPrimitivePaintsCurvesFillsAndMixedRibbons() throws Exception { + byte[] pdf = sheet(); + assertThat(pdf).isNotEmpty(); + assertThat(new String(pdf, 0, 5, StandardCharsets.US_ASCII)).isEqualTo("%PDF-"); + + BufferedImage page = VISUAL.renderPages(pdf).get(0); + BufferedImage blank = VISUAL.renderPages(blankPage()).get(0); + ImageDiff.Result diff = ImageDiff.compare(blank, page, 8); + assertThat(diff.mismatchedPixelCount()) + .as("path curves, fills and ribbons must paint over the blank page (%s)", diff.summary()) + .isGreaterThan(500L); + + Path out = Path.of("target/visual-tests/path/path_primitive.pdf"); + Files.createDirectories(out.getParent()); + Files.write(out, pdf); + javax.imageio.ImageIO.write(page, "png", + out.resolveSibling("path_primitive.png").toFile()); + } + + private static byte[] blankPage() throws Exception { + try (DocumentSession document = GraphCompose.document() + .pageSize(360, 320) + .margin(DocumentInsets.of(22)) + .create()) { + document.pageFlow().name("Blank").build(); + return document.toPdfBytes(); + } + } + + private static byte[] sheet() throws Exception { + try (DocumentSession document = GraphCompose.document() + .pageSize(360, 320) + .margin(DocumentInsets.of(22)) + .create()) { + // Stroked S-wave — pure cubic Bézier spans, no tessellation. + document.add(new PathNode("Wave", 316, 60, + List.of(moveTo(0.0, 0.5), + cubicTo(0.25, 1.1, 0.25, -0.1, 0.5, 0.5), + cubicTo(0.75, 1.1, 0.75, -0.1, 1.0, 0.5)), + null, + DocumentStroke.of(DocumentColor.rgb(20, 60, 120), 2.4), + DocumentInsets.zero(), DocumentInsets.bottom(14))); + + // Filled blob — closed curves with overshooting control points. + document.add(new PathNode("Blob", 110, 96, + List.of(moveTo(0.5, 1.0), + cubicTo(1.12, 0.94, 0.96, 0.08, 0.5, 0.0), + cubicTo(0.04, 0.08, -0.12, 0.94, 0.5, 1.0), + close()), + DocumentColor.rgb(235, 205, 160), + DocumentStroke.of(DocumentColor.rgb(140, 90, 30), 1.4), + DocumentInsets.zero(), DocumentInsets.bottom(14))); + + // Mixed ribbon — lines + curves in one closed, filled subpath. + document.add(new PathNode("Ribbon", 316, 64, + List.of(moveTo(0.0, 0.85), + lineTo(0.18, 0.85), + cubicTo(0.42, 1.05, 0.58, 0.55, 0.82, 0.75), + lineTo(1.0, 0.75), + lineTo(1.0, 0.15), + cubicTo(0.6, -0.05, 0.4, 0.45, 0.0, 0.25), + close()), + DocumentColor.rgb(208, 226, 213), + DocumentStroke.of(DocumentColor.rgb(60, 110, 80), 1.2), + DocumentInsets.zero(), DocumentInsets.zero())); + + return document.toPdfBytes(); + } + } +} diff --git a/src/test/resources/layout-snapshots/document/path_primitive.json b/src/test/resources/layout-snapshots/document/path_primitive.json new file mode 100644 index 00000000..531a6d9b --- /dev/null +++ b/src/test/resources/layout-snapshots/document/path_primitive.json @@ -0,0 +1,77 @@ +{ + "formatVersion" : "2.0", + "canvas" : { + "pageWidth" : 240.0, + "pageHeight" : 200.0, + "innerWidth" : 216.0, + "innerHeight" : 176.0, + "margin" : { + "top" : 12.0, + "right" : 12.0, + "bottom" : 12.0, + "left" : 12.0 + } + }, + "totalPages" : 1, + "nodes" : [ { + "path" : "Wave[0]", + "entityName" : "Wave", + "entityKind" : "Path", + "parentPath" : null, + "childIndex" : 0, + "depth" : 1, + "layer" : 1, + "computedX" : 12.0, + "computedY" : 118.0, + "placementX" : 12.0, + "placementY" : 118.0, + "placementWidth" : 200.0, + "placementHeight" : 70.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 200.0, + "contentHeight" : 70.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 8.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + }, { + "path" : "Blob[1]", + "entityName" : "Blob", + "entityKind" : "Path", + "parentPath" : null, + "childIndex" : 1, + "depth" : 1, + "layer" : 1, + "computedX" : 12.0, + "computedY" : 30.0, + "placementX" : 12.0, + "placementY" : 30.0, + "placementWidth" : 90.0, + "placementHeight" : 80.0, + "startPage" : 0, + "endPage" : 0, + "contentWidth" : 90.0, + "contentHeight" : 80.0, + "margin" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + }, + "padding" : { + "top" : 0.0, + "right" : 0.0, + "bottom" : 0.0, + "left" : 0.0 + } + } ] +}