diff --git a/CHANGELOG.md b/CHANGELOG.md
index a2d6260b9..6a84ddae3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -67,6 +67,17 @@ Entries land here as they merge.
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.
+- **SVG path import** (`@since 1.8.0`, **beta** — annotated `@Beta` while
+ the surface hardens against real-world exporter output). `SvgPath.parse(d)` /
+ `parse(d, viewBox...)` in the new `document.svg` package lowers the full
+ SVG 1.1 path grammar — absolute/relative `M L H V C S Q T A Z`, implicit
+ repetition, quadratics (exact cubic elevation), smooth shorthands, and
+ elliptical arcs (deterministic W3C endpoint-to-center conversion, ≤90°
+ cubic slices) — into normalized, y-flipped `DocumentPathSegment`s.
+ `PathBuilder.svg(svgPath)` drops the result straight into `addPath(...)`:
+ any icon's `d` string renders as native PDF curves, no tessellation.
+ Syntax errors report the character position; fills keep SVG's default
+ non-zero winding rule.
- **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 21492291b..a9e402d92 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 5fcd6074b..354243614 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -88,7 +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) |
+| [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) |
| [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) |
@@ -363,7 +363,9 @@ 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. Strokes can be dashed via
-`dashed(on, off, ...)` — the pattern follows the curve.
+`dashed(on, off, ...)` — the pattern follows the curve. SVG icons drop in
+through `SvgPath.parse(d, viewBox...)` + `.svg(...)` — the full path
+grammar (arcs included) lands as native curves.
```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 5587cec31..8f6d6a8d9 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
@@ -5,6 +5,7 @@
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.document.svg.SvgPath;
import com.demcha.examples.support.ExampleOutputPaths;
import java.nio.file.Path;
@@ -38,6 +39,12 @@ public final class VectorPathExample {
private static final DocumentColor MOSS = DocumentColor.rgb(208, 226, 213);
private static final DocumentColor MOSS_EDGE = DocumentColor.rgb(60, 110, 80);
+ /** Material Icons "favorite" path data (Apache 2.0), viewBox 0 0 24 24. */
+ private static final String MATERIAL_HEART_D =
+ "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"
+ + "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"
+ + "c0 3.78-3.4 6.86-8.55 11.54L12 21.35z";
+
private VectorPathExample() {
}
@@ -52,7 +59,7 @@ public static Path generate() throws Exception {
Path pdfFile = ExampleOutputPaths.prepare("features/shapes", "vector-path.pdf");
try (DocumentSession document = GraphCompose.document(pdfFile)
- .pageSize(420, 540)
+ .pageSize(420, 660)
.margin(DocumentInsets.of(28))
.create()) {
document.pageFlow(page -> page
@@ -86,6 +93,13 @@ public static Path generate() throws Exception {
.stroke(DocumentStroke.of(INK, 1.8))
.dashed(6, 3)
.margin(DocumentInsets.bottom(16)))
+ .addParagraph("SVG path import — Material 'favorite' heart via SvgPath.parse")
+ .addPath(path -> path
+ .name("HeartIcon")
+ .size(72, 72)
+ .svg(SvgPath.parse(MATERIAL_HEART_D, 0, 0, 24, 24))
+ .fillColor(DocumentColor.rgb(196, 30, 58))
+ .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/dsl/PathBuilder.java b/src/main/java/com/demcha/compose/document/dsl/PathBuilder.java
index 6a59eb32a..eb347bea5 100644
--- a/src/main/java/com/demcha/compose/document/dsl/PathBuilder.java
+++ b/src/main/java/com/demcha/compose/document/dsl/PathBuilder.java
@@ -1,7 +1,9 @@
package com.demcha.compose.document.dsl;
+import com.demcha.compose.document.api.Beta;
import com.demcha.compose.document.node.PathNode;
import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.svg.SvgPath;
import com.demcha.compose.document.style.DocumentDashPattern;
import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.style.DocumentPathSegment;
@@ -10,6 +12,7 @@
import java.awt.*;
import java.util.ArrayList;
import java.util.List;
+import java.util.Objects;
/**
* Builder for semantic vector-path nodes — free-form design shapes with
@@ -151,6 +154,23 @@ public PathBuilder closePath() {
return this;
}
+ /**
+ * Appends every segment of a parsed SVG path. The segments arrive
+ * already normalized to the unit box with the y-axis flipped, so the
+ * only remaining decision is the node's {@link #size(double, double)} —
+ * use {@code svgPath.aspectRatio()} to keep the icon's proportions.
+ *
+ * @param svgPath parsed SVG path data
+ * @return this builder
+ * @since 1.8.0
+ */
+ @Beta
+ public PathBuilder svg(SvgPath svgPath) {
+ Objects.requireNonNull(svgPath, "svgPath");
+ segments.addAll(svgPath.segments());
+ return this;
+ }
+
/**
* Sets the fill color (non-zero winding rule).
*
diff --git a/src/main/java/com/demcha/compose/document/svg/SvgPath.java b/src/main/java/com/demcha/compose/document/svg/SvgPath.java
new file mode 100644
index 000000000..e6265a681
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/svg/SvgPath.java
@@ -0,0 +1,192 @@
+package com.demcha.compose.document.svg;
+
+import com.demcha.compose.document.api.Beta;
+import com.demcha.compose.document.style.DocumentPathSegment;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Parsed SVG path data (<path d="…">), lowered to the
+ * canonical {@link DocumentPathSegment} set and normalized into the unit
+ * box with the y-axis flipped to the PDF orientation.
+ *
+ *
The full SVG 1.1 path grammar is supported: absolute and relative
+ * {@code M L H V C S Q T A Z}, implicit command repetition (including the
+ * lineto chain after a moveto), quadratic curves (converted exactly to
+ * cubics), smooth shorthands ({@code S}/{@code T} control-point
+ * reflection), and elliptical arcs (converted deterministically to cubic
+ * spans of at most 90° each via the W3C endpoint-to-center algorithm).
+ * Everything a vector editor exports as a {@code d} string lands here as
+ * lines, cubics and closes — ready for native PDF curve rendering.
+ *
+ * Normalization uses the supplied viewBox when one is given (the usual
+ * icon workflow, keeping the icon's designed padding) or the tight bounding
+ * box of the parsed geometry otherwise. SVG's y-down user space is flipped
+ * to the y-up convention of {@code DocumentPathSegment}; fills keep SVG's
+ * default non-zero winding semantics.
+ *
+ * {@code
+ * SvgPath heart = SvgPath.parse(MATERIAL_HEART_D, 0, 0, 24, 24);
+ * flow.addPath(path -> path
+ * .size(64, 64 / heart.aspectRatio())
+ * .svg(heart)
+ * .fillColor(crimson));
+ * }
+ *
+ * Beta: the SVG surface is new in 1.8.0 and marked {@link Beta}
+ * while it hardens against real-world files — the API may still adjust in a
+ * minor release based on feedback.
+ *
+ * @author Artem Demchyshyn
+ * @since 1.8.0
+ */
+@Beta
+public final class SvgPath {
+
+ private static final double EPS = 1e-9;
+
+ private final List segments;
+ private final double sourceWidth;
+ private final double sourceHeight;
+
+ private SvgPath(List segments, double sourceWidth, double sourceHeight) {
+ this.segments = List.copyOf(segments);
+ this.sourceWidth = sourceWidth;
+ this.sourceHeight = sourceHeight;
+ }
+
+ /**
+ * Parses SVG path data and normalizes it against the tight bounding box
+ * of the parsed geometry (anchor and control points).
+ *
+ * @param d SVG path data, e.g. {@code "M0 0 C20 40 40 40 60 0 Z"}
+ * @return parsed, normalized path
+ * @throws IllegalArgumentException if the data is empty, does not start
+ * with a moveto, or contains a syntax
+ * error (the message carries the
+ * character position)
+ */
+ public static SvgPath parse(String d) {
+ List ops = new SvgPathParser(d).parse();
+ double[] box = tightBox(ops);
+ return normalize(ops, box[0], box[1], box[2], box[3]);
+ }
+
+ /**
+ * Parses SVG path data and normalizes it against the given viewBox —
+ * the usual workflow for icons, where the viewBox preserves the icon's
+ * designed padding inside its frame.
+ *
+ * @param d SVG path data
+ * @param minX viewBox min-x
+ * @param minY viewBox min-y
+ * @param width viewBox width; must be finite and positive
+ * @param height viewBox height; must be finite and positive
+ * @return parsed, normalized path
+ * @throws IllegalArgumentException on syntax errors or a non-positive box
+ */
+ public static SvgPath parse(String d, double minX, double minY, double width, double height) {
+ if (!(width > 0) || Double.isInfinite(width) || !(height > 0) || Double.isInfinite(height)) {
+ throw new IllegalArgumentException(
+ "viewBox must have finite positive dimensions: " + width + " x " + height);
+ }
+ return normalize(new SvgPathParser(d).parse(), minX, minY, width, height);
+ }
+
+ /**
+ * Returns the normalized segments (unit box, y-up), ready for
+ * {@code PathBuilder.svg(...)} or a {@code PathNode}.
+ *
+ * @return immutable normalized segment list
+ */
+ public List segments() {
+ return segments;
+ }
+
+ /**
+ * Returns the source box width in SVG user units (viewBox width or tight
+ * bounding-box width).
+ *
+ * @return source width
+ */
+ public double sourceWidth() {
+ return sourceWidth;
+ }
+
+ /**
+ * Returns the source box height in SVG user units.
+ *
+ * @return source height
+ */
+ public double sourceHeight() {
+ return sourceHeight;
+ }
+
+ /**
+ * Returns the width-to-height ratio of the source box, for sizing the
+ * target {@code PathNode} proportionally.
+ *
+ * @return {@code sourceWidth() / sourceHeight()}
+ */
+ public double aspectRatio() {
+ return sourceWidth / sourceHeight;
+ }
+
+ // ------------------------------------------------------------------
+ // Normalization (y-flip into the unit box)
+ // ------------------------------------------------------------------
+
+ /** Op encoding: [0]=kind (0 move, 1 line, 2 cubic, 3 close), then coords. */
+ private static SvgPath normalize(List ops,
+ double minX, double minY, double width, double height) {
+ // Degenerate extents (a purely horizontal or vertical path with a
+ // tight box) keep coordinates finite by centring on the flat axis.
+ boolean flatX = width < EPS;
+ boolean flatY = height < EPS;
+ double w = flatX ? 1.0 : width;
+ double h = flatY ? 1.0 : height;
+ double topY = minY + height;
+
+ List out = new ArrayList<>(ops.size());
+ for (double[] op : ops) {
+ switch ((int) op[0]) {
+ case 0 -> out.add(DocumentPathSegment.moveTo(nx(op[1], minX, w, flatX), ny(op[2], topY, h, flatY)));
+ case 1 -> out.add(DocumentPathSegment.lineTo(nx(op[1], minX, w, flatX), ny(op[2], topY, h, flatY)));
+ case 2 -> out.add(DocumentPathSegment.cubicTo(
+ nx(op[1], minX, w, flatX), ny(op[2], topY, h, flatY),
+ nx(op[3], minX, w, flatX), ny(op[4], topY, h, flatY),
+ nx(op[5], minX, w, flatX), ny(op[6], topY, h, flatY)));
+ default -> out.add(DocumentPathSegment.close());
+ }
+ }
+ return new SvgPath(out, width < EPS ? 1.0 : width, height < EPS ? 1.0 : height);
+ }
+
+ private static double nx(double x, double minX, double w, boolean flat) {
+ return flat ? 0.5 : (x - minX) / w;
+ }
+
+ private static double ny(double y, double topY, double h, boolean flat) {
+ return flat ? 0.5 : (topY - y) / h;
+ }
+
+ private static double[] tightBox(List ops) {
+ double minX = Double.POSITIVE_INFINITY;
+ double minY = Double.POSITIVE_INFINITY;
+ double maxX = Double.NEGATIVE_INFINITY;
+ double maxY = Double.NEGATIVE_INFINITY;
+ for (double[] op : ops) {
+ for (int i = 1; i + 1 < op.length; i += 2) {
+ minX = Math.min(minX, op[i]);
+ maxX = Math.max(maxX, op[i]);
+ minY = Math.min(minY, op[i + 1]);
+ maxY = Math.max(maxY, op[i + 1]);
+ }
+ }
+ if (minX > maxX) {
+ throw new IllegalArgumentException("SVG path data contains no drawable geometry");
+ }
+ return new double[]{minX, minY, maxX - minX, maxY - minY};
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/svg/SvgPathParser.java b/src/main/java/com/demcha/compose/document/svg/SvgPathParser.java
new file mode 100644
index 000000000..34ca597d7
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/svg/SvgPathParser.java
@@ -0,0 +1,347 @@
+package com.demcha.compose.document.svg;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Internal SVG path-data scanner and state machine: turns a {@code d} string
+ * into absolute drawing ops in SVG user space (y down). Quadratics elevate
+ * exactly to cubics, smooth shorthands reflect the previous control point,
+ * and elliptical arcs convert via the W3C endpoint-to-center algorithm into
+ * cubic slices of at most 90°. {@link SvgPath} owns normalization and the
+ * public surface; every branch here is driven through {@code SvgPathTest}.
+ *
+ * Op encoding: {@code [0]}=kind (0 move, 1 line, 2 cubic, 3 close),
+ * followed by coordinate pairs.
+ */
+
+final class SvgPathParser {
+
+ private static final double EPS = 1e-9;
+ private final String d;
+ private int pos;
+
+ private double curX;
+ private double curY;
+ private double startX;
+ private double startY;
+ private double lastCubicC2X;
+ private double lastCubicC2Y;
+ private double lastQuadCX;
+ private double lastQuadCY;
+ private char lastCmd;
+
+ private final List ops = new ArrayList<>();
+
+ SvgPathParser(String d) {
+ this.d = d == null ? "" : d;
+ }
+
+ List parse() {
+ skipSeparators();
+ if (pos >= d.length()) {
+ throw new IllegalArgumentException("SVG path data is empty");
+ }
+ char first = d.charAt(pos);
+ if (first != 'M' && first != 'm') {
+ throw new IllegalArgumentException(
+ "SVG path data must start with a moveto, found '" + first + "' at position " + pos);
+ }
+ char cmd = 0;
+ while (true) {
+ skipSeparators();
+ if (pos >= d.length()) {
+ break;
+ }
+ char c = d.charAt(pos);
+ if (isCommand(c)) {
+ cmd = c;
+ pos++;
+ } else if (cmd == 0) {
+ throw fail("a command letter");
+ } else {
+ // Implicit repetition: a moveto chain continues as lineto.
+ if (cmd == 'M') {
+ cmd = 'L';
+ } else if (cmd == 'm') {
+ cmd = 'l';
+ }
+ }
+ apply(cmd);
+ }
+ return ops;
+ }
+
+ private void apply(char cmd) {
+ boolean rel = Character.isLowerCase(cmd);
+ switch (Character.toUpperCase(cmd)) {
+ case 'M' -> {
+ double x = number() + (rel ? curX : 0);
+ double y = number() + (rel ? curY : 0);
+ moveTo(x, y);
+ }
+ case 'L' -> {
+ double x = number() + (rel ? curX : 0);
+ double y = number() + (rel ? curY : 0);
+ lineTo(x, y);
+ }
+ case 'H' -> lineTo(number() + (rel ? curX : 0), curY);
+ case 'V' -> lineTo(curX, number() + (rel ? curY : 0));
+ case 'C' -> {
+ double c1x = number() + (rel ? curX : 0);
+ double c1y = number() + (rel ? curY : 0);
+ double c2x = number() + (rel ? curX : 0);
+ double c2y = number() + (rel ? curY : 0);
+ double x = number() + (rel ? curX : 0);
+ double y = number() + (rel ? curY : 0);
+ cubicTo(c1x, c1y, c2x, c2y, x, y);
+ }
+ case 'S' -> {
+ double c1x = curX;
+ double c1y = curY;
+ if (lastCmd == 'C' || lastCmd == 'S') {
+ c1x = 2 * curX - lastCubicC2X;
+ c1y = 2 * curY - lastCubicC2Y;
+ }
+ double c2x = number() + (rel ? curX : 0);
+ double c2y = number() + (rel ? curY : 0);
+ double x = number() + (rel ? curX : 0);
+ double y = number() + (rel ? curY : 0);
+ cubicTo(c1x, c1y, c2x, c2y, x, y);
+ lastCmd = 'S';
+ return;
+ }
+ case 'Q' -> {
+ double qx = number() + (rel ? curX : 0);
+ double qy = number() + (rel ? curY : 0);
+ double x = number() + (rel ? curX : 0);
+ double y = number() + (rel ? curY : 0);
+ quadTo(qx, qy, x, y);
+ lastCmd = 'Q';
+ return;
+ }
+ case 'T' -> {
+ double qx = curX;
+ double qy = curY;
+ if (lastCmd == 'Q' || lastCmd == 'T') {
+ qx = 2 * curX - lastQuadCX;
+ qy = 2 * curY - lastQuadCY;
+ }
+ double x = number() + (rel ? curX : 0);
+ double y = number() + (rel ? curY : 0);
+ quadTo(qx, qy, x, y);
+ lastCmd = 'T';
+ return;
+ }
+ case 'A' -> {
+ double rx = number();
+ double ry = number();
+ double rotationDegrees = number();
+ boolean largeArc = flag();
+ boolean sweep = flag();
+ double x = number() + (rel ? curX : 0);
+ double y = number() + (rel ? curY : 0);
+ arcTo(rx, ry, rotationDegrees, largeArc, sweep, x, y);
+ }
+ case 'Z' -> {
+ ops.add(new double[]{3});
+ curX = startX;
+ curY = startY;
+ }
+ default -> throw fail("a supported command");
+ }
+ lastCmd = Character.toUpperCase(cmd);
+ }
+
+ private void moveTo(double x, double y) {
+ ops.add(new double[]{0, x, y});
+ curX = x;
+ curY = y;
+ startX = x;
+ startY = y;
+ }
+
+ private void lineTo(double x, double y) {
+ ops.add(new double[]{1, x, y});
+ curX = x;
+ curY = y;
+ }
+
+ private void cubicTo(double c1x, double c1y, double c2x, double c2y, double x, double y) {
+ ops.add(new double[]{2, c1x, c1y, c2x, c2y, x, y});
+ lastCubicC2X = c2x;
+ lastCubicC2Y = c2y;
+ curX = x;
+ curY = y;
+ }
+
+ /** Exact quadratic→cubic elevation: c = q0 + 2/3 (q − q0) etc. */
+ private void quadTo(double qx, double qy, double x, double y) {
+ double c1x = curX + 2.0 / 3.0 * (qx - curX);
+ double c1y = curY + 2.0 / 3.0 * (qy - curY);
+ double c2x = x + 2.0 / 3.0 * (qx - x);
+ double c2y = y + 2.0 / 3.0 * (qy - y);
+ cubicTo(c1x, c1y, c2x, c2y, x, y);
+ lastQuadCX = qx;
+ lastQuadCY = qy;
+ }
+
+ /**
+ * W3C SVG 1.1 F.6: endpoint parameterization → center, then cubic
+ * spans of at most 90° each ({@code t = 4/3 · tan(δ/4)}).
+ */
+ private void arcTo(double rx, double ry, double rotationDegrees,
+ boolean largeArc, boolean sweep, double x, double y) {
+ if (rx == 0 || ry == 0) {
+ lineTo(x, y);
+ return;
+ }
+ double x1 = curX;
+ double y1 = curY;
+ if (Math.abs(x1 - x) < EPS && Math.abs(y1 - y) < EPS) {
+ return;
+ }
+ rx = Math.abs(rx);
+ ry = Math.abs(ry);
+ double phi = Math.toRadians(rotationDegrees % 360.0);
+ double cosPhi = Math.cos(phi);
+ double sinPhi = Math.sin(phi);
+
+ double dx2 = (x1 - x) / 2.0;
+ double dy2 = (y1 - y) / 2.0;
+ double x1p = cosPhi * dx2 + sinPhi * dy2;
+ double y1p = -sinPhi * dx2 + cosPhi * dy2;
+
+ double lambda = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry);
+ if (lambda > 1) {
+ double scale = Math.sqrt(lambda);
+ rx *= scale;
+ ry *= scale;
+ }
+
+ double rx2 = rx * rx;
+ double ry2 = ry * ry;
+ double num = rx2 * ry2 - rx2 * y1p * y1p - ry2 * x1p * x1p;
+ double den = rx2 * y1p * y1p + ry2 * x1p * x1p;
+ double co = Math.sqrt(Math.max(0, num / den)) * (largeArc != sweep ? 1 : -1);
+ double cxp = co * rx * y1p / ry;
+ double cyp = -co * ry * x1p / rx;
+ double cx = cosPhi * cxp - sinPhi * cyp + (x1 + x) / 2.0;
+ double cy = sinPhi * cxp + cosPhi * cyp + (y1 + y) / 2.0;
+
+ double theta1 = angle(1, 0, (x1p - cxp) / rx, (y1p - cyp) / ry);
+ double delta = angle((x1p - cxp) / rx, (y1p - cyp) / ry,
+ (-x1p - cxp) / rx, (-y1p - cyp) / ry) % (2 * Math.PI);
+ if (!sweep && delta > 0) {
+ delta -= 2 * Math.PI;
+ } else if (sweep && delta < 0) {
+ delta += 2 * Math.PI;
+ }
+
+ int slices = (int) Math.ceil(Math.abs(delta) / (Math.PI / 2.0));
+ double sliceDelta = delta / slices;
+ double t = 4.0 / 3.0 * Math.tan(sliceDelta / 4.0);
+ double theta = theta1;
+ for (int i = 0; i < slices; i++) {
+ double cosT = Math.cos(theta);
+ double sinT = Math.sin(theta);
+ double thetaNext = theta + sliceDelta;
+ double cosN = Math.cos(thetaNext);
+ double sinN = Math.sin(thetaNext);
+
+ double sx = cx + rx * cosPhi * cosT - ry * sinPhi * sinT;
+ double sy = cy + rx * sinPhi * cosT + ry * cosPhi * sinT;
+ double ex = cx + rx * cosPhi * cosN - ry * sinPhi * sinN;
+ double ey = cy + rx * sinPhi * cosN + ry * cosPhi * sinN;
+
+ double dSx = -rx * cosPhi * sinT - ry * sinPhi * cosT;
+ double dSy = -rx * sinPhi * sinT + ry * cosPhi * cosT;
+ double dEx = -rx * cosPhi * sinN - ry * sinPhi * cosN;
+ double dEy = -rx * sinPhi * sinN + ry * cosPhi * cosN;
+
+ cubicTo(sx + t * dSx, sy + t * dSy, ex - t * dEx, ey - t * dEy, ex, ey);
+ theta = thetaNext;
+ }
+ curX = x;
+ curY = y;
+ }
+
+ private static double angle(double ux, double uy, double vx, double vy) {
+ return Math.atan2(ux * vy - uy * vx, ux * vx + uy * vy);
+ }
+
+ // ---- scanning -------------------------------------------------
+
+ private static boolean isCommand(char c) {
+ return "MmLlHhVvCcSsQqTtAaZz".indexOf(c) >= 0;
+ }
+
+ private void skipSeparators() {
+ while (pos < d.length()) {
+ char c = d.charAt(pos);
+ if (c == ',' || Character.isWhitespace(c)) {
+ pos++;
+ } else {
+ break;
+ }
+ }
+ }
+
+ private double number() {
+ skipSeparators();
+ int start = pos;
+ if (pos < d.length() && (d.charAt(pos) == '+' || d.charAt(pos) == '-')) {
+ pos++;
+ }
+ int digits = 0;
+ while (pos < d.length() && Character.isDigit(d.charAt(pos))) {
+ pos++;
+ digits++;
+ }
+ if (pos < d.length() && d.charAt(pos) == '.') {
+ pos++;
+ while (pos < d.length() && Character.isDigit(d.charAt(pos))) {
+ pos++;
+ digits++;
+ }
+ }
+ if (digits == 0) {
+ throw fail("a number");
+ }
+ if (pos < d.length() && (d.charAt(pos) == 'e' || d.charAt(pos) == 'E')) {
+ int expStart = pos;
+ pos++;
+ if (pos < d.length() && (d.charAt(pos) == '+' || d.charAt(pos) == '-')) {
+ pos++;
+ }
+ int expDigits = 0;
+ while (pos < d.length() && Character.isDigit(d.charAt(pos))) {
+ pos++;
+ expDigits++;
+ }
+ if (expDigits == 0) {
+ pos = expStart;
+ }
+ }
+ double value = Double.parseDouble(d.substring(start, pos));
+ if (Double.isNaN(value) || Double.isInfinite(value)) {
+ throw fail("a finite number");
+ }
+ return value;
+ }
+
+ /** Arc flags are single characters, so {@code "011"} is three flags-and-digits, not one number. */
+ private boolean flag() {
+ skipSeparators();
+ if (pos >= d.length() || (d.charAt(pos) != '0' && d.charAt(pos) != '1')) {
+ throw fail("an arc flag (0 or 1)");
+ }
+ return d.charAt(pos++) == '1';
+ }
+
+ private IllegalArgumentException fail(String expected) {
+ String found = pos < d.length() ? "'" + d.charAt(pos) + "'" : "end of data";
+ return new IllegalArgumentException(
+ "Expected " + expected + " at position " + pos + " in SVG path data, found " + found);
+ }
+}
diff --git a/src/main/java/com/demcha/compose/document/svg/package-info.java b/src/main/java/com/demcha/compose/document/svg/package-info.java
new file mode 100644
index 000000000..955d7fd53
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/svg/package-info.java
@@ -0,0 +1,16 @@
+/**
+ * SVG interoperability for the canonical document model.
+ *
+ * The entry point is {@link com.demcha.compose.document.svg.SvgPath},
+ * which parses SVG path data ({@code }) into the canonical
+ * {@link com.demcha.compose.document.style.DocumentPathSegment} set —
+ * normalized, y-flipped, and ready for
+ * {@code PathBuilder.svg(...)} / {@code PathNode}. Curves render as native
+ * PDF operators; nothing is tessellated.
+ *
+ * Beta: the whole package is marked beta for the 1.8 cycle while
+ * it hardens against real-world exporter output.
+ *
+ * @since 1.8.0
+ */
+package com.demcha.compose.document.svg;
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 690de275c..510240f31 100644
--- a/src/test/java/com/demcha/compose/document/dsl/PathBuilderTest.java
+++ b/src/test/java/com/demcha/compose/document/dsl/PathBuilderTest.java
@@ -9,6 +9,7 @@
import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.style.DocumentPathSegment;
import com.demcha.compose.document.style.DocumentStroke;
+import com.demcha.compose.document.svg.SvgPath;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
@@ -64,6 +65,21 @@ void dashedFlowsThroughToTheNode() {
assertThat(node.dashPattern().segments()).containsExactly(4.0, 2.0);
}
+ @Test
+ void svgBridgeAppendsParsedSegments() {
+ PathNode node = new PathBuilder()
+ .size(24, 24)
+ .svg(SvgPath.parse("M0 0 L10 0 L5 8 Z", 0, 0, 10, 8))
+ .fillColor(DocumentColor.rgb(196, 30, 58))
+ .build();
+
+ assertThat(node.segments()).hasSize(4);
+ assertThat(node.segments().get(0))
+ .isInstanceOf(DocumentPathSegment.MoveTo.class);
+ assertThat(node.segments().get(3))
+ .isInstanceOf(DocumentPathSegment.Close.class);
+ }
+
@Test
void nodeValidationFlowsThroughBuild() {
PathBuilder missingMoveTo = new PathBuilder()
diff --git a/src/test/java/com/demcha/compose/document/svg/SvgPathTest.java b/src/test/java/com/demcha/compose/document/svg/SvgPathTest.java
new file mode 100644
index 000000000..a8aff6fc3
--- /dev/null
+++ b/src/test/java/com/demcha/compose/document/svg/SvgPathTest.java
@@ -0,0 +1,200 @@
+package com.demcha.compose.document.svg;
+
+import com.demcha.compose.document.style.DocumentPathSegment;
+import com.demcha.compose.document.style.DocumentPathSegment.Close;
+import com.demcha.compose.document.style.DocumentPathSegment.CubicTo;
+import com.demcha.compose.document.style.DocumentPathSegment.LineTo;
+import com.demcha.compose.document.style.DocumentPathSegment.MoveTo;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.within;
+
+/**
+ * Grammar and geometry coverage for {@link SvgPath}: every command family,
+ * relative forms, implicit repetition, exact quad→cubic elevation, arc→cubic
+ * conversion, viewBox vs tight-box normalization with the y-flip, and the
+ * syntax-error contract.
+ */
+class SvgPathTest {
+
+ @Test
+ void absoluteTriangleNormalizesAndFlipsY() {
+ SvgPath path = SvgPath.parse("M0 0 L10 0 L5 8 Z", 0, 0, 10, 8);
+
+ List s = path.segments();
+ assertThat(s).hasSize(4);
+ MoveTo move = (MoveTo) s.get(0);
+ // SVG (0,0) is the top-left of the viewBox → flipped to y=1.
+ assertThat(move.x()).isCloseTo(0.0, within(1e-9));
+ assertThat(move.y()).isCloseTo(1.0, within(1e-9));
+ LineTo apex = (LineTo) s.get(2);
+ assertThat(apex.x()).isCloseTo(0.5, within(1e-9));
+ assertThat(apex.y()).isCloseTo(0.0, within(1e-9));
+ assertThat(s.get(3)).isInstanceOf(Close.class);
+ }
+
+ @Test
+ void relativeCommandsAccumulateAndTightBoxNormalizes() {
+ SvgPath path = SvgPath.parse("m1 1 l2 0 l0 2 z");
+
+ List s = path.segments();
+ assertThat(s).hasSize(4);
+ // User points (1,1) → (3,1) → (3,3); tight box is [1..3]×[1..3].
+ MoveTo move = (MoveTo) s.get(0);
+ assertThat(move.x()).isCloseTo(0.0, within(1e-9));
+ assertThat(move.y()).isCloseTo(1.0, within(1e-9));
+ LineTo last = (LineTo) s.get(2);
+ assertThat(last.x()).isCloseTo(1.0, within(1e-9));
+ assertThat(last.y()).isCloseTo(0.0, within(1e-9));
+ assertThat(path.aspectRatio()).isCloseTo(1.0, within(1e-9));
+ }
+
+ @Test
+ void horizontalAndVerticalShorthandsDraw() {
+ SvgPath path = SvgPath.parse("M0 0 H10 V5 h-4 v-2", 0, 0, 10, 5);
+
+ assertThat(path.segments()).hasSize(5);
+ assertThat(path.segments().subList(1, 5)).allMatch(LineTo.class::isInstance);
+ LineTo h = (LineTo) path.segments().get(1);
+ assertThat(h.x()).isCloseTo(1.0, within(1e-9));
+ assertThat(h.y()).isCloseTo(1.0, within(1e-9));
+ }
+
+ @Test
+ void implicitLinetoContinuesAMovetoChain() {
+ SvgPath path = SvgPath.parse("M0 0 10 0 10 10", 0, 0, 10, 10);
+
+ assertThat(path.segments()).hasSize(3);
+ assertThat(path.segments().get(0)).isInstanceOf(MoveTo.class);
+ assertThat(path.segments().get(1)).isInstanceOf(LineTo.class);
+ assertThat(path.segments().get(2)).isInstanceOf(LineTo.class);
+ }
+
+ @Test
+ void quadraticElevatesToCubicExactly() {
+ SvgPath path = SvgPath.parse("M0 0 Q5 10 10 0", 0, 0, 10, 10);
+
+ CubicTo cubic = (CubicTo) path.segments().get(1);
+ // c1 = q0 + 2/3(q − q0) = (10/3, 20/3) in user space; y flips.
+ assertThat(cubic.control1X()).isCloseTo(1.0 / 3.0, within(1e-9));
+ assertThat(cubic.control1Y()).isCloseTo(1.0 / 3.0, within(1e-9));
+ assertThat(cubic.control2X()).isCloseTo(2.0 / 3.0, within(1e-9));
+ assertThat(cubic.control2Y()).isCloseTo(1.0 / 3.0, within(1e-9));
+ assertThat(cubic.y()).isCloseTo(1.0, within(1e-9));
+ }
+
+ @Test
+ void smoothCubicReflectsThePreviousControl() {
+ SvgPath path = SvgPath.parse("M0 5 C0 0 5 0 5 5 S10 10 10 5", 0, 0, 10, 10);
+
+ CubicTo smooth = (CubicTo) path.segments().get(2);
+ // Reflection of (5,0) about (5,5) is (5,10) in user space → y=0 flipped.
+ assertThat(smooth.control1X()).isCloseTo(0.5, within(1e-9));
+ assertThat(smooth.control1Y()).isCloseTo(0.0, within(1e-9));
+ }
+
+ @Test
+ void smoothQuadReflectsThePreviousControl() {
+ SvgPath path = SvgPath.parse("M0 5 Q2.5 0 5 5 T10 5", 0, 0, 10, 10);
+
+ assertThat(path.segments()).hasSize(3);
+ CubicTo second = (CubicTo) path.segments().get(2);
+ // Reflected quad control is (7.5, 10) user → c1 = cur + 2/3 (q − cur)
+ // = (5 + 5/3, 5 + 10/3); flipped y = 1 − 25/30.
+ assertThat(second.control1X()).isCloseTo((5 + 5.0 / 3.0) / 10.0, within(1e-9));
+ assertThat(second.control1Y()).isCloseTo(1.0 - (5 + 10.0 / 3.0) / 10.0, within(1e-9));
+ }
+
+ @Test
+ void quarterArcApproximatesTheCircleConstant() {
+ // Quarter circle radius 10 from (10,0) to (0,10), centre at origin.
+ SvgPath path = SvgPath.parse("M10 0 A10 10 0 0 1 0 10", 0, 0, 10, 10);
+
+ assertThat(path.segments()).hasSize(2);
+ CubicTo arc = (CubicTo) path.segments().get(1);
+ // First control ≈ (10, κ·10) in user space, κ = 0.5523.
+ assertThat(arc.control1X()).isCloseTo(1.0, within(1e-3));
+ assertThat(arc.control1Y()).isCloseTo(1.0 - 0.5523, within(1e-3));
+ assertThat(arc.x()).isCloseTo(0.0, within(1e-9));
+ assertThat(arc.y()).isCloseTo(0.0, within(1e-9));
+ }
+
+ @Test
+ void largeArcSplitsIntoNinetyDegreeSlices() {
+ // Three-quarter circle: 270° → three cubic slices.
+ SvgPath path = SvgPath.parse("M10 5 A5 5 0 1 1 5 0", -1, -1, 12, 12);
+
+ long cubics = path.segments().stream().filter(CubicTo.class::isInstance).count();
+ assertThat(cubics).isEqualTo(3);
+ }
+
+ @Test
+ void zeroRadiusArcDegradesToALine() {
+ SvgPath path = SvgPath.parse("M0 0 A0 5 0 0 1 10 10", 0, 0, 10, 10);
+
+ assertThat(path.segments().get(1)).isInstanceOf(LineTo.class);
+ }
+
+ @Test
+ void compactArcFlagsParse() {
+ // Flags are single characters: "011 1" carries large-arc=0, sweep=1,
+ // then the endpoint 1,1 — a form vector editors actually emit.
+ SvgPath path = SvgPath.parse("M0 0 a1 1 0 011 1", 0, 0, 2, 2);
+
+ assertThat(path.segments().stream().anyMatch(CubicTo.class::isInstance)).isTrue();
+ }
+
+ @Test
+ void materialHeartParsesEndToEnd() {
+ String d = "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"
+ + "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"
+ + "c0 3.78-3.4 6.86-8.55 11.54L12 21.35z";
+ SvgPath heart = SvgPath.parse(d, 0, 0, 24, 24);
+
+ assertThat(heart.segments().size()).isGreaterThan(8);
+ assertThat(heart.segments().get(0)).isInstanceOf(MoveTo.class);
+ assertThat(heart.aspectRatio()).isCloseTo(1.0, within(1e-9));
+ // Every coordinate landed inside the viewBox-normalized unit range.
+ for (DocumentPathSegment segment : heart.segments()) {
+ if (segment instanceof LineTo line) {
+ assertThat(line.x()).isBetween(0.0, 1.0);
+ assertThat(line.y()).isBetween(0.0, 1.0);
+ }
+ }
+ }
+
+ @Test
+ void flatPathsKeepFiniteCoordinates() {
+ SvgPath path = SvgPath.parse("M0 5 H10");
+
+ LineTo line = (LineTo) path.segments().get(1);
+ assertThat(line.y()).isCloseTo(0.5, within(1e-9));
+ assertThat(path.sourceHeight()).isCloseTo(1.0, within(1e-9));
+ }
+
+ @Test
+ void syntaxErrorsCarryThePosition() {
+ assertThatThrownBy(() -> SvgPath.parse("M0 0 X5 5"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("position");
+ assertThatThrownBy(() -> SvgPath.parse("L1 1"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("must start with a moveto");
+ assertThatThrownBy(() -> SvgPath.parse(""))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("empty");
+ assertThatThrownBy(() -> SvgPath.parse("M0"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("number");
+ assertThatThrownBy(() -> SvgPath.parse("M0 0 A1 1 0 2 0 5 5"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("arc flag");
+ assertThatThrownBy(() -> SvgPath.parse("M0 0 L1 1", 0, 0, 0, 10))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("viewBox");
+ }
+}