Skip to content

Commit 21e8723

Browse files
committed
feat(svg): SvgPath.parse - full SVG path grammar to native PathNode curves
New document.svg package: SvgPath.parse(d) / parse(d, viewBox) lowers the complete SVG 1.1 path grammar (absolute/relative M L H V C S Q T A Z, implicit repetition, exact quadratic-to-cubic elevation, S/T control reflection, elliptical arcs via the W3C endpoint-to-center algorithm in <=90-degree cubic slices, single-char arc flags) into normalized, y-flipped DocumentPathSegments; PathBuilder.svg(svgPath) drops them into addPath(...). Scanner/state machine lives in package-private SvgPathParser (extracted pre-commit to keep SvgPath under the 500-line rule; every branch driven through SvgPathTest). 14 parser tests incl. golden Material-heart icon, kappa check on a quarter arc, 270-degree slice count, compact flags, position-carrying syntax errors; svg-bridge builder test. Example ships the Material heart rendered at 72pt (page grew to 660pt, preview stays single-page). Full gate: 1276 tests, BUILD SUCCESS.
1 parent 41dae5c commit 21e8723

10 files changed

Lines changed: 809 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,16 @@ Entries land here as they merge.
6767
every flow builder authors design shapes directly, and
6868
`dashed(on, off, ...)` makes the stroke dashed with the same
6969
`DocumentDashPattern` contract as lines — the pattern follows the curve.
70+
- **SVG path import** (`@since 1.8.0`). `SvgPath.parse(d)` /
71+
`parse(d, viewBox...)` in the new `document.svg` package lowers the full
72+
SVG 1.1 path grammar — absolute/relative `M L H V C S Q T A Z`, implicit
73+
repetition, quadratics (exact cubic elevation), smooth shorthands, and
74+
elliptical arcs (deterministic W3C endpoint-to-center conversion, ≤90°
75+
cubic slices) — into normalized, y-flipped `DocumentPathSegment`s.
76+
`PathBuilder.svg(svgPath)` drops the result straight into `addPath(...)`:
77+
any icon's `d` string renders as native PDF curves, no tessellation.
78+
Syntax errors report the character position; fills keep SVG's default
79+
non-zero winding rule.
7080
- **Inline sparklines** (`@since 1.8.0`). `RichText.sparkline(w, h, color,
7181
values...)` draws a filled mini-area silhouette on the text baseline, and
7282
`sparklineLine(w, h, thickness, color, values...)` a constant-thickness line
227 Bytes
Binary file not shown.

examples/README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ are with the canonical DSL, then jump to its detailed section below.
8888
| Example | What it shows | Preview · Source |
8989
|---|---|---|
9090
| [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) |
91-
| [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) |
91+
| [Vector paths (Bézier)](#vector-paths-bézier) | `addPath(...)` + `SvgPath.parse(...)` — design shapes and imported SVG icons as native curves; zero tessellation | [PDF](../assets/readme/examples/vector-path.pdf) · [Source](src/main/java/com/demcha/examples/features/shapes/VectorPathExample.java) |
9292
| [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) |
9393
| [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) |
9494
| [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) |
@@ -363,7 +363,9 @@ ribbons in one closed subpath. Curves render as native PDF `curveTo`
363363
operators — perfectly smooth at any zoom, no tessellation. Coordinates
364364
are normalized to the shape's box (`(0,0)` bottom-left, `y` up) and
365365
control points may overshoot it. Strokes can be dashed via
366-
`dashed(on, off, ...)` — the pattern follows the curve.
366+
`dashed(on, off, ...)` — the pattern follows the curve. SVG icons drop in
367+
through `SvgPath.parse(d, viewBox...)` + `.svg(...)` — the full path
368+
grammar (arcs included) lands as native curves.
367369

368370
```java
369371
flow.addPath(path -> path

examples/src/main/java/com/demcha/examples/features/shapes/VectorPathExample.java

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import com.demcha.compose.document.style.DocumentColor;
66
import com.demcha.compose.document.style.DocumentInsets;
77
import com.demcha.compose.document.style.DocumentStroke;
8+
import com.demcha.compose.document.svg.SvgPath;
89
import com.demcha.examples.support.ExampleOutputPaths;
910

1011
import java.nio.file.Path;
@@ -38,6 +39,12 @@ public final class VectorPathExample {
3839
private static final DocumentColor MOSS = DocumentColor.rgb(208, 226, 213);
3940
private static final DocumentColor MOSS_EDGE = DocumentColor.rgb(60, 110, 80);
4041

42+
/** Material Icons "favorite" path data (Apache 2.0), viewBox 0 0 24 24. */
43+
private static final String MATERIAL_HEART_D =
44+
"M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3"
45+
+ "c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5"
46+
+ "c0 3.78-3.4 6.86-8.55 11.54L12 21.35z";
47+
4148
private VectorPathExample() {
4249
}
4350

@@ -52,7 +59,7 @@ public static Path generate() throws Exception {
5259
Path pdfFile = ExampleOutputPaths.prepare("features/shapes", "vector-path.pdf");
5360

5461
try (DocumentSession document = GraphCompose.document(pdfFile)
55-
.pageSize(420, 540)
62+
.pageSize(420, 660)
5663
.margin(DocumentInsets.of(28))
5764
.create()) {
5865
document.pageFlow(page -> page
@@ -86,6 +93,13 @@ public static Path generate() throws Exception {
8693
.stroke(DocumentStroke.of(INK, 1.8))
8794
.dashed(6, 3)
8895
.margin(DocumentInsets.bottom(16)))
96+
.addParagraph("SVG path import — Material 'favorite' heart via SvgPath.parse")
97+
.addPath(path -> path
98+
.name("HeartIcon")
99+
.size(72, 72)
100+
.svg(SvgPath.parse(MATERIAL_HEART_D, 0, 0, 24, 24))
101+
.fillColor(DocumentColor.rgb(196, 30, 58))
102+
.margin(DocumentInsets.bottom(16)))
89103
.addParagraph("Mixed ribbon — lines and curves in one closed, filled subpath")
90104
.addPath(path -> path
91105
.name("Ribbon")

src/main/java/com/demcha/compose/document/dsl/PathBuilder.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.demcha.compose.document.node.PathNode;
44
import com.demcha.compose.document.style.DocumentColor;
5+
import com.demcha.compose.document.svg.SvgPath;
56
import com.demcha.compose.document.style.DocumentDashPattern;
67
import com.demcha.compose.document.style.DocumentInsets;
78
import com.demcha.compose.document.style.DocumentPathSegment;
@@ -10,6 +11,7 @@
1011
import java.awt.*;
1112
import java.util.ArrayList;
1213
import java.util.List;
14+
import java.util.Objects;
1315

1416
/**
1517
* Builder for semantic vector-path nodes — free-form design shapes with
@@ -151,6 +153,22 @@ public PathBuilder closePath() {
151153
return this;
152154
}
153155

156+
/**
157+
* Appends every segment of a parsed SVG path. The segments arrive
158+
* already normalized to the unit box with the y-axis flipped, so the
159+
* only remaining decision is the node's {@link #size(double, double)} —
160+
* use {@code svgPath.aspectRatio()} to keep the icon's proportions.
161+
*
162+
* @param svgPath parsed SVG path data
163+
* @return this builder
164+
* @since 1.8.0
165+
*/
166+
public PathBuilder svg(SvgPath svgPath) {
167+
Objects.requireNonNull(svgPath, "svgPath");
168+
segments.addAll(svgPath.segments());
169+
return this;
170+
}
171+
154172
/**
155173
* Sets the fill color (non-zero winding rule).
156174
*
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
package com.demcha.compose.document.svg;
2+
3+
import com.demcha.compose.document.style.DocumentPathSegment;
4+
5+
import java.util.ArrayList;
6+
import java.util.List;
7+
8+
/**
9+
* Parsed SVG path data (<code>&lt;path d="…"&gt;</code>), lowered to the
10+
* canonical {@link DocumentPathSegment} set and normalized into the unit
11+
* box with the y-axis flipped to the PDF orientation.
12+
*
13+
* <p>The full SVG 1.1 path grammar is supported: absolute and relative
14+
* {@code M L H V C S Q T A Z}, implicit command repetition (including the
15+
* lineto chain after a moveto), quadratic curves (converted exactly to
16+
* cubics), smooth shorthands ({@code S}/{@code T} control-point
17+
* reflection), and elliptical arcs (converted deterministically to cubic
18+
* spans of at most 90° each via the W3C endpoint-to-center algorithm).
19+
* Everything a vector editor exports as a {@code d} string lands here as
20+
* lines, cubics and closes — ready for native PDF curve rendering.</p>
21+
*
22+
* <p>Normalization uses the supplied viewBox when one is given (the usual
23+
* icon workflow, keeping the icon's designed padding) or the tight bounding
24+
* box of the parsed geometry otherwise. SVG's y-down user space is flipped
25+
* to the y-up convention of {@code DocumentPathSegment}; fills keep SVG's
26+
* default non-zero winding semantics.</p>
27+
*
28+
* <pre>{@code
29+
* SvgPath heart = SvgPath.parse(MATERIAL_HEART_D, 0, 0, 24, 24);
30+
* flow.addPath(path -> path
31+
* .size(64, 64 / heart.aspectRatio())
32+
* .svg(heart)
33+
* .fillColor(crimson));
34+
* }</pre>
35+
*
36+
* @author Artem Demchyshyn
37+
* @since 1.8.0
38+
*/
39+
public final class SvgPath {
40+
41+
private static final double EPS = 1e-9;
42+
43+
private final List<DocumentPathSegment> segments;
44+
private final double sourceWidth;
45+
private final double sourceHeight;
46+
47+
private SvgPath(List<DocumentPathSegment> segments, double sourceWidth, double sourceHeight) {
48+
this.segments = List.copyOf(segments);
49+
this.sourceWidth = sourceWidth;
50+
this.sourceHeight = sourceHeight;
51+
}
52+
53+
/**
54+
* Parses SVG path data and normalizes it against the tight bounding box
55+
* of the parsed geometry (anchor and control points).
56+
*
57+
* @param d SVG path data, e.g. {@code "M0 0 C20 40 40 40 60 0 Z"}
58+
* @return parsed, normalized path
59+
* @throws IllegalArgumentException if the data is empty, does not start
60+
* with a moveto, or contains a syntax
61+
* error (the message carries the
62+
* character position)
63+
*/
64+
public static SvgPath parse(String d) {
65+
List<double[]> ops = new SvgPathParser(d).parse();
66+
double[] box = tightBox(ops);
67+
return normalize(ops, box[0], box[1], box[2], box[3]);
68+
}
69+
70+
/**
71+
* Parses SVG path data and normalizes it against the given viewBox —
72+
* the usual workflow for icons, where the viewBox preserves the icon's
73+
* designed padding inside its frame.
74+
*
75+
* @param d SVG path data
76+
* @param minX viewBox min-x
77+
* @param minY viewBox min-y
78+
* @param width viewBox width; must be finite and positive
79+
* @param height viewBox height; must be finite and positive
80+
* @return parsed, normalized path
81+
* @throws IllegalArgumentException on syntax errors or a non-positive box
82+
*/
83+
public static SvgPath parse(String d, double minX, double minY, double width, double height) {
84+
if (!(width > 0) || Double.isInfinite(width) || !(height > 0) || Double.isInfinite(height)) {
85+
throw new IllegalArgumentException(
86+
"viewBox must have finite positive dimensions: " + width + " x " + height);
87+
}
88+
return normalize(new SvgPathParser(d).parse(), minX, minY, width, height);
89+
}
90+
91+
/**
92+
* Returns the normalized segments (unit box, y-up), ready for
93+
* {@code PathBuilder.svg(...)} or a {@code PathNode}.
94+
*
95+
* @return immutable normalized segment list
96+
*/
97+
public List<DocumentPathSegment> segments() {
98+
return segments;
99+
}
100+
101+
/**
102+
* Returns the source box width in SVG user units (viewBox width or tight
103+
* bounding-box width).
104+
*
105+
* @return source width
106+
*/
107+
public double sourceWidth() {
108+
return sourceWidth;
109+
}
110+
111+
/**
112+
* Returns the source box height in SVG user units.
113+
*
114+
* @return source height
115+
*/
116+
public double sourceHeight() {
117+
return sourceHeight;
118+
}
119+
120+
/**
121+
* Returns the width-to-height ratio of the source box, for sizing the
122+
* target {@code PathNode} proportionally.
123+
*
124+
* @return {@code sourceWidth() / sourceHeight()}
125+
*/
126+
public double aspectRatio() {
127+
return sourceWidth / sourceHeight;
128+
}
129+
130+
// ------------------------------------------------------------------
131+
// Normalization (y-flip into the unit box)
132+
// ------------------------------------------------------------------
133+
134+
/** Op encoding: [0]=kind (0 move, 1 line, 2 cubic, 3 close), then coords. */
135+
private static SvgPath normalize(List<double[]> ops,
136+
double minX, double minY, double width, double height) {
137+
// Degenerate extents (a purely horizontal or vertical path with a
138+
// tight box) keep coordinates finite by centring on the flat axis.
139+
boolean flatX = width < EPS;
140+
boolean flatY = height < EPS;
141+
double w = flatX ? 1.0 : width;
142+
double h = flatY ? 1.0 : height;
143+
double topY = minY + height;
144+
145+
List<DocumentPathSegment> out = new ArrayList<>(ops.size());
146+
for (double[] op : ops) {
147+
switch ((int) op[0]) {
148+
case 0 -> out.add(DocumentPathSegment.moveTo(nx(op[1], minX, w, flatX), ny(op[2], topY, h, flatY)));
149+
case 1 -> out.add(DocumentPathSegment.lineTo(nx(op[1], minX, w, flatX), ny(op[2], topY, h, flatY)));
150+
case 2 -> out.add(DocumentPathSegment.cubicTo(
151+
nx(op[1], minX, w, flatX), ny(op[2], topY, h, flatY),
152+
nx(op[3], minX, w, flatX), ny(op[4], topY, h, flatY),
153+
nx(op[5], minX, w, flatX), ny(op[6], topY, h, flatY)));
154+
default -> out.add(DocumentPathSegment.close());
155+
}
156+
}
157+
return new SvgPath(out, width < EPS ? 1.0 : width, height < EPS ? 1.0 : height);
158+
}
159+
160+
private static double nx(double x, double minX, double w, boolean flat) {
161+
return flat ? 0.5 : (x - minX) / w;
162+
}
163+
164+
private static double ny(double y, double topY, double h, boolean flat) {
165+
return flat ? 0.5 : (topY - y) / h;
166+
}
167+
168+
private static double[] tightBox(List<double[]> ops) {
169+
double minX = Double.POSITIVE_INFINITY;
170+
double minY = Double.POSITIVE_INFINITY;
171+
double maxX = Double.NEGATIVE_INFINITY;
172+
double maxY = Double.NEGATIVE_INFINITY;
173+
for (double[] op : ops) {
174+
for (int i = 1; i + 1 < op.length; i += 2) {
175+
minX = Math.min(minX, op[i]);
176+
maxX = Math.max(maxX, op[i]);
177+
minY = Math.min(minY, op[i + 1]);
178+
maxY = Math.max(maxY, op[i + 1]);
179+
}
180+
}
181+
if (minX > maxX) {
182+
throw new IllegalArgumentException("SVG path data contains no drawable geometry");
183+
}
184+
return new double[]{minX, minY, maxX - minX, maxY - minY};
185+
}
186+
}

0 commit comments

Comments
 (0)