diff --git a/CHANGELOG.md b/CHANGELOG.md index f9521442..a2d6260b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,7 +64,9 @@ Entries land here as they merge. and/or stroke. This is the leaf vehicle for smooth chart lines, decorative design shapes, and future SVG path import. DSL: `addPath(p -> p.moveTo(...).curveTo(...).closePath().fillColor(...))` on - every flow builder authors design shapes directly. + every flow builder authors design shapes directly, and + `dashed(on, off, ...)` makes the stroke dashed with the same + `DocumentDashPattern` contract as lines — the pattern follows the curve. - **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 index 8324e9e7..21492291 100644 Binary files a/assets/readme/examples/vector-path.pdf and b/assets/readme/examples/vector-path.pdf differ diff --git a/examples/README.md b/examples/README.md index 73d2410b..5fcd6074 100644 --- a/examples/README.md +++ b/examples/README.md @@ -362,7 +362,8 @@ Free-form design shapes with native cubic Bézier curves through 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. +control points may overshoot it. Strokes can be dashed via +`dashed(on, off, ...)` — the pattern follows the curve. ```java flow.addPath(path -> path 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 index eb32c04e..5587cec3 100644 --- a/examples/src/main/java/com/demcha/examples/features/shapes/VectorPathExample.java +++ b/examples/src/main/java/com/demcha/examples/features/shapes/VectorPathExample.java @@ -52,7 +52,7 @@ public static Path generate() throws Exception { Path pdfFile = ExampleOutputPaths.prepare("features/shapes", "vector-path.pdf"); try (DocumentSession document = GraphCompose.document(pdfFile) - .pageSize(420, 420) + .pageSize(420, 540) .margin(DocumentInsets.of(28)) .create()) { document.pageFlow(page -> page @@ -76,6 +76,16 @@ public static Path generate() throws Exception { .fillColor(SAND) .stroke(DocumentStroke.of(SAND_EDGE, 1.4)) .margin(DocumentInsets.bottom(16))) + .addParagraph("Dashed Bézier wave — dashed(6, 3) follows the curve") + .addPath(path -> path + .name("DashedWave") + .size(364, 44) + .moveTo(0.0, 0.5) + .curveTo(0.25, 1.2, 0.25, -0.2, 0.5, 0.5) + .curveTo(0.75, 1.2, 0.75, -0.2, 1.0, 0.5) + .stroke(DocumentStroke.of(INK, 1.8)) + .dashed(6, 3) + .margin(DocumentInsets.bottom(16))) .addParagraph("Mixed ribbon — lines and curves in one closed, filled subpath") .addPath(path -> path .name("Ribbon") diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfLineFragmentRenderHandler.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfLineFragmentRenderHandler.java index a76e6c2a..2e87179a 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfLineFragmentRenderHandler.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfLineFragmentRenderHandler.java @@ -25,18 +25,6 @@ public final class PdfLineFragmentRenderHandler public PdfLineFragmentRenderHandler() { } - private static void applyDashPattern(PDPageContentStream stream, DocumentDashPattern dash) throws IOException { - if (dash == null || dash.isSolid()) { - return; - } - List segments = dash.segments(); - float[] dashArray = new float[segments.size()]; - for (int i = 0; i < dashArray.length; i++) { - dashArray[i] = segments.get(i).floatValue(); - } - stream.setLineDashPattern(dashArray, 0f); - } - @Override public Class payloadType() { return LineFragmentPayload.class; @@ -56,7 +44,7 @@ public void render(PlacedFragment fragment, try { stream.setStrokingColor(stroke.strokeColor().color()); stream.setLineWidth((float) stroke.width()); - applyDashPattern(stream, payload.dashPattern()); + PdfShapeGeometry.applyDashPattern(stream, payload.dashPattern()); stream.moveTo((float) (fragment.x() + payload.startX()), (float) (fragment.y() + payload.startY())); stream.lineTo((float) (fragment.x() + payload.endX()), (float) (fragment.y() + payload.endY())); stream.stroke(); 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 index 119566b4..b127b0a9 100644 --- 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 @@ -42,6 +42,7 @@ public void render(PlacedFragment fragment, float width = (float) fragment.width(); float height = (float) fragment.height(); PdfShapeGeometry.fillAndStrokePath(stream, payload.fillColor(), payload.stroke(), + payload.dashPattern(), 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 9abfa9f9..f11d32b9 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.DocumentDashPattern; import com.demcha.compose.document.style.DocumentPathSegment; import com.demcha.compose.document.style.ShapePoint; import com.demcha.compose.engine.components.content.shape.Stroke; @@ -28,6 +29,20 @@ static void fillAndStrokePath(PDPageContentStream stream, Color fillColor, Stroke stroke, PathEmitter path) throws IOException { + fillAndStrokePath(stream, fillColor, stroke, null, path); + } + + /** + * Variant of {@link #fillAndStrokePath(PDPageContentStream, Color, Stroke, PathEmitter)} + * with an optional dash pattern applied to the stroke inside the saved + * graphics state ({@code null} or {@link DocumentDashPattern#NONE} keeps + * the stroke solid). + */ + static void fillAndStrokePath(PDPageContentStream stream, + Color fillColor, + Stroke stroke, + DocumentDashPattern dashPattern, + PathEmitter path) throws IOException { boolean hasFill = fillColor != null; boolean hasStroke = stroke != null && stroke.strokeColor() != null @@ -42,6 +57,7 @@ static void fillAndStrokePath(PDPageContentStream stream, PdfAlphaSupport.applyStrokeAlpha(stream, stroke.strokeColor().color()); stream.setStrokingColor(stroke.strokeColor().color()); stream.setLineWidth((float) stroke.width()); + applyDashPattern(stream, dashPattern); } if (hasFill) { PdfAlphaSupport.applyFillAlpha(stream, fillColor); @@ -168,6 +184,23 @@ static void roundedRectPath(PDPageContentStream stream, stream.closePath(); } + /** + * Applies a dash pattern to the stream's stroking state. No-op for + * {@code null} or solid patterns. Shared by the line and path renderers + * so both emit identical dash arrays. + */ + static void applyDashPattern(PDPageContentStream stream, DocumentDashPattern dash) throws IOException { + if (dash == null || dash.isSolid()) { + return; + } + List segments = dash.segments(); + float[] dashArray = new float[segments.size()]; + for (int i = 0; i < dashArray.length; i++) { + dashArray[i] = segments.get(i).floatValue(); + } + stream.setLineDashPattern(dashArray, 0f); + } + /** * A path contribution: the caller adds the geometry (ellipse, rectangle, * polygon, …) so the fill/stroke wrapper can be shared. diff --git a/src/main/java/com/demcha/compose/document/chart/LineChartLayout.java b/src/main/java/com/demcha/compose/document/chart/LineChartLayout.java index 93195d1f..403206cf 100644 --- a/src/main/java/com/demcha/compose/document/chart/LineChartLayout.java +++ b/src/main/java/com/demcha/compose/document/chart/LineChartLayout.java @@ -236,7 +236,7 @@ private static ChartPrimitive bezierRun(String name, List run, Documen (end[0] - minX) / w, (end[1] - minY) / h)); } PathNode node = new PathNode(name, w, h, segments, null, stroke, - DocumentInsets.zero(), DocumentInsets.zero()); + DocumentInsets.zero(), DocumentInsets.zero(), null); return new ChartPrimitive(node, minX, minY, w, h); } @@ -289,7 +289,7 @@ private static void emitCurvedArea(List out, String name, segments.add(DocumentPathSegment.close()); PathNode node = new PathNode(name, w, h, segments, fill, null, - DocumentInsets.zero(), DocumentInsets.zero()); + DocumentInsets.zero(), DocumentInsets.zero(), null); out.add(new ChartPrimitive(node, minX, minY, w, h)); } diff --git a/src/main/java/com/demcha/compose/document/dsl/PathBuilder.java b/src/main/java/com/demcha/compose/document/dsl/PathBuilder.java index e95d63f9..6a59eb32 100644 --- a/src/main/java/com/demcha/compose/document/dsl/PathBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/PathBuilder.java @@ -2,6 +2,7 @@ import com.demcha.compose.document.node.PathNode; import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentDashPattern; import com.demcha.compose.document.style.DocumentInsets; import com.demcha.compose.document.style.DocumentPathSegment; import com.demcha.compose.document.style.DocumentStroke; @@ -43,6 +44,7 @@ public final class PathBuilder { private DocumentStroke stroke; private DocumentInsets padding = DocumentInsets.zero(); private DocumentInsets margin = DocumentInsets.zero(); + private DocumentDashPattern dashPattern = DocumentDashPattern.NONE; /** * Creates a path builder. @@ -182,6 +184,32 @@ public PathBuilder stroke(DocumentStroke stroke) { return this; } + /** + * Makes the stroke dashed using alternating on/off lengths in points + * (the same contract as {@code LineBuilder.dashed}). Affects only the + * stroke; fills are unaffected. + * + * @param pattern alternating on/off lengths in points + * @return this builder + */ + public PathBuilder dashed(double... pattern) { + this.dashPattern = DocumentDashPattern.of(pattern); + return this; + } + + /** + * Makes the stroke dashed using a prepared {@link DocumentDashPattern}. + * A {@code null} or {@link DocumentDashPattern#NONE} pattern keeps the + * stroke solid. + * + * @param pattern dash pattern, or {@code null} for solid + * @return this builder + */ + public PathBuilder dashed(DocumentDashPattern pattern) { + this.dashPattern = pattern == null ? DocumentDashPattern.NONE : pattern; + return this; + } + /** * Sets the path padding. * @@ -215,6 +243,6 @@ public PathBuilder margin(DocumentInsets margin) { * added, or the box is not positive */ public PathNode build() { - return new PathNode(name, width, height, segments, fillColor, stroke, padding, margin); + return new PathNode(name, width, height, segments, fillColor, stroke, padding, margin, dashPattern); } } 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 index bad2c5d0..2285539f 100644 --- a/src/main/java/com/demcha/compose/document/layout/definitions/PathDefinition.java +++ b/src/main/java/com/demcha/compose/document/layout/definitions/PathDefinition.java @@ -64,6 +64,7 @@ public List emitFragments(PreparedNode prepared, node.fillColor() == null ? null : node.fillColor().color(), toStroke(node.stroke()), null, - null))); + null, + node.dashPattern()))); } } 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 index 6ad7617e..14b5a660 100644 --- a/src/main/java/com/demcha/compose/document/layout/payloads/PathFragmentPayload.java +++ b/src/main/java/com/demcha/compose/document/layout/payloads/PathFragmentPayload.java @@ -2,6 +2,7 @@ import com.demcha.compose.document.node.DocumentBookmarkOptions; import com.demcha.compose.document.node.DocumentLinkOptions; +import com.demcha.compose.document.style.DocumentDashPattern; import com.demcha.compose.document.style.DocumentPathSegment; import com.demcha.compose.engine.components.content.shape.Stroke; @@ -20,6 +21,8 @@ * @param stroke optional stroke * @param linkOptions optional fragment-level link metadata * @param bookmarkOptions optional fragment-level bookmark metadata + * @param dashPattern dash pattern for the stroke; + * {@link DocumentDashPattern#NONE} is solid * @author Artem Demchyshyn * @since 1.8.0 */ @@ -28,13 +31,15 @@ public record PathFragmentPayload( Color fillColor, Stroke stroke, DocumentLinkOptions linkOptions, - DocumentBookmarkOptions bookmarkOptions + DocumentBookmarkOptions bookmarkOptions, + DocumentDashPattern dashPattern ) implements PdfSemanticFragmentPayload { /** - * Copies the segment list defensively. + * Copies the segment list defensively and normalizes the dash pattern. */ public PathFragmentPayload { Objects.requireNonNull(segments, "segments"); segments = List.copyOf(segments); + dashPattern = dashPattern == null ? DocumentDashPattern.NONE : dashPattern; } } diff --git a/src/main/java/com/demcha/compose/document/node/PathNode.java b/src/main/java/com/demcha/compose/document/node/PathNode.java index 57db84f6..8578bb83 100644 --- a/src/main/java/com/demcha/compose/document/node/PathNode.java +++ b/src/main/java/com/demcha/compose/document/node/PathNode.java @@ -1,6 +1,7 @@ package com.demcha.compose.document.node; import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentDashPattern; import com.demcha.compose.document.style.DocumentInsets; import com.demcha.compose.document.style.DocumentPathSegment; import com.demcha.compose.document.style.DocumentStroke; @@ -28,8 +29,10 @@ * {@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 + * @param padding inner padding + * @param margin outer margin + * @param dashPattern dash pattern for the stroke; defaults to + * {@link DocumentDashPattern#NONE} (solid) * @author Artem Demchyshyn * @since 1.8.0 */ @@ -41,7 +44,8 @@ public record PathNode( DocumentColor fillColor, DocumentStroke stroke, DocumentInsets padding, - DocumentInsets margin + DocumentInsets margin, + DocumentDashPattern dashPattern ) implements DocumentNode { /** * Validates dimensions and the segment list; copy-protects the segments. @@ -61,6 +65,7 @@ public record PathNode( } padding = padding == null ? DocumentInsets.zero() : padding; margin = margin == null ? DocumentInsets.zero() : margin; + dashPattern = dashPattern == null ? DocumentDashPattern.NONE : dashPattern; if (width <= 0 || Double.isNaN(width) || Double.isInfinite(width)) { throw new IllegalArgumentException("width must be finite and positive: " + width); } diff --git a/src/test/java/com/demcha/compose/document/api/PathNodeRenderingTest.java b/src/test/java/com/demcha/compose/document/api/PathNodeRenderingTest.java index a1406502..9e36a5de 100644 --- a/src/test/java/com/demcha/compose/document/api/PathNodeRenderingTest.java +++ b/src/test/java/com/demcha/compose/document/api/PathNodeRenderingTest.java @@ -53,7 +53,7 @@ private static PathNode wave() { 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)); + DocumentInsets.zero(), DocumentInsets.bottom(8), null); } private static PathNode blob() { @@ -64,6 +64,6 @@ private static PathNode blob() { close()), DocumentColor.rgb(235, 205, 160), DocumentStroke.of(DocumentColor.rgb(140, 90, 30), 1.2), - DocumentInsets.zero(), DocumentInsets.zero()); + DocumentInsets.zero(), DocumentInsets.zero(), null); } } diff --git a/src/test/java/com/demcha/compose/document/dsl/PathBuilderTest.java b/src/test/java/com/demcha/compose/document/dsl/PathBuilderTest.java index 5584bd66..690de275 100644 --- a/src/test/java/com/demcha/compose/document/dsl/PathBuilderTest.java +++ b/src/test/java/com/demcha/compose/document/dsl/PathBuilderTest.java @@ -51,6 +51,19 @@ void builderAssemblesTheNode() { assertThat(node.margin().bottom()).isEqualTo(6); } + @Test + void dashedFlowsThroughToTheNode() { + PathNode node = new PathBuilder() + .size(100, 40) + .moveTo(0.0, 0.5) + .lineTo(1.0, 0.5) + .stroke(DocumentStroke.of(DocumentColor.rgb(20, 60, 120), 1.0)) + .dashed(4, 2) + .build(); + + assertThat(node.dashPattern().segments()).containsExactly(4.0, 2.0); + } + @Test void nodeValidationFlowsThroughBuild() { PathBuilder missingMoveTo = new PathBuilder() diff --git a/src/test/java/com/demcha/compose/document/node/PathNodeTest.java b/src/test/java/com/demcha/compose/document/node/PathNodeTest.java index e27fa6be..ef0d4808 100644 --- a/src/test/java/com/demcha/compose/document/node/PathNodeTest.java +++ b/src/test/java/com/demcha/compose/document/node/PathNodeTest.java @@ -1,6 +1,7 @@ package com.demcha.compose.document.node; import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentDashPattern; import com.demcha.compose.document.style.DocumentInsets; import com.demcha.compose.document.style.DocumentPathSegment; import com.demcha.compose.document.style.DocumentStroke; @@ -77,11 +78,17 @@ void segmentListIsCopyProtectedAndImmutable() { void boxDimensionsMustBeFiniteAndPositive() { assertThatThrownBy(() -> new PathNode("Bad", 0, 40, List.of(moveTo(0, 0), lineTo(1, 1)), null, STROKE, - DocumentInsets.zero(), DocumentInsets.zero())) + DocumentInsets.zero(), DocumentInsets.zero(), null)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("width must be finite and positive"); } + @Test + void dashPatternDefaultsToSolid() { + assertThat(node(List.of(moveTo(0, 0), lineTo(1, 1))).dashPattern()) + .isEqualTo(DocumentDashPattern.NONE); + } + @Test void nodeKindIsPath() { assertThat(node(List.of(moveTo(0, 0), lineTo(1, 1))).nodeKind()).isEqualTo("Path"); @@ -89,6 +96,6 @@ void nodeKindIsPath() { private static PathNode node(List segments) { return new PathNode("P", 120, 60, segments, null, STROKE, - DocumentInsets.zero(), DocumentInsets.zero()); + DocumentInsets.zero(), DocumentInsets.zero(), null); } } diff --git a/src/test/java/com/demcha/testing/visual/PathPrimitiveDemoTest.java b/src/test/java/com/demcha/testing/visual/PathPrimitiveDemoTest.java index 2f77f356..fc71457f 100644 --- a/src/test/java/com/demcha/testing/visual/PathPrimitiveDemoTest.java +++ b/src/test/java/com/demcha/testing/visual/PathPrimitiveDemoTest.java @@ -4,6 +4,7 @@ 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.DocumentDashPattern; import com.demcha.compose.document.style.DocumentInsets; import com.demcha.compose.document.style.DocumentStroke; import com.demcha.compose.testing.visual.ImageDiff; @@ -74,7 +75,7 @@ private static byte[] sheet() throws Exception { 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))); + DocumentInsets.zero(), DocumentInsets.bottom(14), null)); // Filled blob — closed curves with overshooting control points. document.add(new PathNode("Blob", 110, 96, @@ -84,7 +85,17 @@ private static byte[] sheet() throws Exception { close()), DocumentColor.rgb(235, 205, 160), DocumentStroke.of(DocumentColor.rgb(140, 90, 30), 1.4), - DocumentInsets.zero(), DocumentInsets.bottom(14))); + DocumentInsets.zero(), DocumentInsets.bottom(14), null)); + + // Dashed Bézier wave — dash pattern follows the curve. + document.add(new PathNode("DashedWave", 316, 40, + List.of(moveTo(0.0, 0.5), + cubicTo(0.25, 1.2, 0.25, -0.2, 0.5, 0.5), + cubicTo(0.75, 1.2, 0.75, -0.2, 1.0, 0.5)), + null, + DocumentStroke.of(DocumentColor.rgb(150, 60, 20), 1.8), + DocumentInsets.zero(), DocumentInsets.bottom(14), + DocumentDashPattern.of(5, 3))); // Mixed ribbon — lines + curves in one closed, filled subpath. document.add(new PathNode("Ribbon", 316, 64, @@ -97,7 +108,7 @@ private static byte[] sheet() throws Exception { close()), DocumentColor.rgb(208, 226, 213), DocumentStroke.of(DocumentColor.rgb(60, 110, 80), 1.2), - DocumentInsets.zero(), DocumentInsets.zero())); + DocumentInsets.zero(), DocumentInsets.zero(), null)); return document.toPdfBytes(); }