Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file modified assets/readme/examples/vector-path.pdf
Binary file not shown.
6 changes: 4 additions & 2 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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() {
}

Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand Down
20 changes: 20 additions & 0 deletions src/main/java/com/demcha/compose/document/dsl/PathBuilder.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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).
*
Expand Down
192 changes: 192 additions & 0 deletions src/main/java/com/demcha/compose/document/svg/SvgPath.java
Original file line number Diff line number Diff line change
@@ -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 (<code>&lt;path d="…"&gt;</code>), lowered to the
* canonical {@link DocumentPathSegment} set and normalized into the unit
* box with the y-axis flipped to the PDF orientation.
*
* <p>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.</p>
*
* <p>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.</p>
*
* <pre>{@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));
* }</pre>
*
* <p><b>Beta:</b> 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.</p>
*
* @author Artem Demchyshyn
* @since 1.8.0
*/
@Beta
public final class SvgPath {

private static final double EPS = 1e-9;

private final List<DocumentPathSegment> segments;
private final double sourceWidth;
private final double sourceHeight;

private SvgPath(List<DocumentPathSegment> 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<double[]> 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<DocumentPathSegment> 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<double[]> 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<DocumentPathSegment> 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<double[]> 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};
}
}
Loading