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"); + } +}