Skip to content

Commit fe67b3f

Browse files
authored
Merge pull request #172 from DemchaAV/feat/svg-path-import
feat(svg): SvgPath.parse - full SVG path grammar to native PathNode curves
2 parents 41dae5c + 524bbd4 commit fe67b3f

10 files changed

Lines changed: 821 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,17 @@ 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`, **beta** — annotated `@Beta` while
71+
the surface hardens against real-world exporter output). `SvgPath.parse(d)` /
72+
`parse(d, viewBox...)` in the new `document.svg` package lowers the full
73+
SVG 1.1 path grammar — absolute/relative `M L H V C S Q T A Z`, implicit
74+
repetition, quadratics (exact cubic elevation), smooth shorthands, and
75+
elliptical arcs (deterministic W3C endpoint-to-center conversion, ≤90°
76+
cubic slices) — into normalized, y-flipped `DocumentPathSegment`s.
77+
`PathBuilder.svg(svgPath)` drops the result straight into `addPath(...)`:
78+
any icon's `d` string renders as native PDF curves, no tessellation.
79+
Syntax errors report the character position; fills keep SVG's default
80+
non-zero winding rule.
7081
- **Inline sparklines** (`@since 1.8.0`). `RichText.sparkline(w, h, color,
7182
values...)` draws a filled mini-area silhouette on the text baseline, and
7283
`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: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package com.demcha.compose.document.dsl;
22

3+
import com.demcha.compose.document.api.Beta;
34
import com.demcha.compose.document.node.PathNode;
45
import com.demcha.compose.document.style.DocumentColor;
6+
import com.demcha.compose.document.svg.SvgPath;
57
import com.demcha.compose.document.style.DocumentDashPattern;
68
import com.demcha.compose.document.style.DocumentInsets;
79
import com.demcha.compose.document.style.DocumentPathSegment;
@@ -10,6 +12,7 @@
1012
import java.awt.*;
1113
import java.util.ArrayList;
1214
import java.util.List;
15+
import java.util.Objects;
1316

1417
/**
1518
* Builder for semantic vector-path nodes — free-form design shapes with
@@ -151,6 +154,23 @@ public PathBuilder closePath() {
151154
return this;
152155
}
153156

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

0 commit comments

Comments
 (0)