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
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,15 @@ Entries land here as they merge.
`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.
non-zero winding rule. On top of it, `SvgIcon.read(file)` / `parse(xml)`
reads the practical subset of a whole SVG file — every `<path>` plus
`rect` / `circle` / `ellipse` / `line` / `polyline` / `polygon` lowered to
path data, `<g>` nesting with `translate` / `scale` / `rotate` / `matrix`
transforms (affine maps are exact on Bézier control points), and
`fill` / `stroke` / `stroke-width` styling with SVG inheritance and
defaults — into ordered layers, and `addSvgIcon(icon, width)` stacks them
back-to-front on the page. The XML reader refuses DOCTYPEs (no XXE);
gradients, CSS, text and filters stay deliberately out of scope.
- **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.
5 changes: 3 additions & 2 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,8 +364,9 @@ 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. SVG icons drop in
through `SvgPath.parse(d, viewBox...)` + `.svg(...)` — the full path
grammar (arcs included) lands as native curves.
through `SvgPath.parse(d, viewBox...)` + `.svg(...)`, or whole files via
`SvgIcon.read(file)` + `addSvgIcon(icon, width)` — multi-layer icons with
group transforms and per-layer paints, all 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.SvgIcon;
import com.demcha.compose.document.svg.SvgPath;
import com.demcha.examples.support.ExampleOutputPaths;

Expand Down Expand Up @@ -45,6 +46,14 @@ public final class VectorPathExample {
+ "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";

/** Inline two-tone badge: tinted disc behind the Material heart. */
private static final String TWO_TONE_BADGE_SVG = """
<svg viewBox="0 0 24 24">
<circle cx="12" cy="12" r="11" fill="#fde9e3"/>
<path fill="#c41e3a" d="%s"/>
</svg>
""".formatted(MATERIAL_HEART_D);

private VectorPathExample() {
}

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

try (DocumentSession document = GraphCompose.document(pdfFile)
.pageSize(420, 660)
.pageSize(420, 780)
.margin(DocumentInsets.of(28))
.create()) {
document.pageFlow(page -> page
Expand Down Expand Up @@ -100,6 +109,8 @@ public static Path generate() throws Exception {
.svg(SvgPath.parse(MATERIAL_HEART_D, 0, 0, 24, 24))
.fillColor(DocumentColor.rgb(196, 30, 58))
.margin(DocumentInsets.bottom(16)))
.addParagraph("Whole-file icon — SvgIcon.read/parse stacks every layer")
.addSvgIcon(SvgIcon.parse(TWO_TONE_BADGE_SVG), 64)
.addParagraph("Mixed ribbon — lines and curves in one closed, filled subpath")
.addPath(path -> path
.name("Ribbon")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,34 @@ public T addPath(Consumer<PathBuilder> spec) {
return add(BuilderSupport.configure(new PathBuilder(), spec).build());
}

/**
* Adds a multi-layer SVG icon at the given width, keeping the icon's
* aspect ratio. Layers stack back-to-front exactly as in the source
* file; every curve renders as native PDF geometry.
*
* @param icon parsed SVG icon
* @param width target icon width in points
* @return this builder
* @since 1.8.0
*/
@com.demcha.compose.document.api.Beta
public T addSvgIcon(com.demcha.compose.document.svg.SvgIcon icon, double width) {
Objects.requireNonNull(icon, "icon");
double height = width / icon.aspectRatio();
return addLayerStack(stack -> {
for (int i = 0; i < icon.layers().size(); i++) {
com.demcha.compose.document.svg.SvgIcon.Layer layer = icon.layers().get(i);
stack.layer(new PathBuilder()
.name("SvgLayer" + i)
.size(width, height)
.svg(layer.geometry())
.fillColor(layer.fill())
.stroke(layer.stroke())
.build());
}
});
}

/**
* Adds a filled circle ellipse — shortcut for
* {@code addEllipse(e -> e.circle(diameter).fillColor(fillColor))}.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
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;
import com.demcha.compose.document.style.DocumentStroke;
import com.demcha.compose.document.svg.SvgPath;

import java.awt.*;
import java.util.ArrayList;
Expand Down
141 changes: 141 additions & 0 deletions src/main/java/com/demcha/compose/document/svg/SvgIcon.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package com.demcha.compose.document.svg;

import com.demcha.compose.document.api.Beta;
import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentStroke;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Objects;

/**
* A multi-layer vector icon read from the practical subset of an SVG file:
* the {@code viewBox}, every {@code <path>} (plus {@code rect}, {@code
* circle}, {@code ellipse}, {@code line}, {@code polyline} and {@code
* polygon}, lowered to path data), {@code <g>} nesting with accumulated
* {@code transform} attributes ({@code translate} / {@code scale} /
* {@code rotate} / {@code matrix} — affine maps are exact on Bézier control
* points), and per-element {@code fill} / {@code stroke} /
* {@code stroke-width} styling with SVG's inheritance and defaults
* (missing {@code fill} paints black, {@code fill="none"} skips the fill).
*
* <p>Each layer is one {@link SvgPath} with its resolved paint, in document
* order — render them back-to-front. The DSL does exactly that:
* {@code flow.addSvgIcon(icon, 48)} stacks the layers into the page at the
* requested width with the icon's own aspect ratio.</p>
*
* <p>Out of scope (deliberately, this is an icon reader, not a browser):
* gradients, CSS stylesheets and classes, text, masks, clip paths,
* filters, {@code <use>} references, nested {@code <svg>} viewBoxes (inner
* frames recurse but their coordinates stay in the outer space), and
* animations. The XML reader refuses
* DOCTYPEs, so external-entity tricks cannot reach the file system.</p>
*
* <pre>{@code
* SvgIcon logo = SvgIcon.read(Path.of("assets/logo.svg"));
* flow.addSvgIcon(logo, 48);
* }</pre>
*
* <p><b>Beta:</b> the SVG surface is new in 1.8.0 and marked {@link Beta}
* while it hardens against real-world exporter output.</p>
*
* @author Artem Demchyshyn
* @since 1.8.0
*/
@Beta
public final class SvgIcon {

private final List<Layer> layers;
private final double sourceWidth;
private final double sourceHeight;

SvgIcon(List<Layer> layers, double sourceWidth, double sourceHeight) {
this.layers = List.copyOf(layers);
this.sourceWidth = sourceWidth;
this.sourceHeight = sourceHeight;
}

/**
* Reads and parses an SVG file.
*
* @param file path to the SVG file
* @return parsed icon
* @throws IOException if the file cannot be read
* @throws IllegalArgumentException if the document is not parseable SVG,
* has no viewBox or usable size, or
* contains no drawable geometry
*/
public static SvgIcon read(Path file) throws IOException {
Objects.requireNonNull(file, "file");
return parse(Files.readString(file, StandardCharsets.UTF_8));
}

/**
* Parses SVG markup.
*
* @param svgXml the SVG document text
* @return parsed icon
* @throws IllegalArgumentException if the document is not parseable SVG,
* has no viewBox or usable size, or
* contains no drawable geometry
*/
public static SvgIcon parse(String svgXml) {
return SvgIconReader.read(svgXml);
}

/**
* Returns the icon's layers in document order (paint back-to-front).
*
* @return immutable layer list; never empty
*/
public List<Layer> layers() {
return layers;
}

/**
* Returns the icon frame width in SVG user units.
*
* @return viewBox (or width attribute) width
*/
public double sourceWidth() {
return sourceWidth;
}

/**
* Returns the icon frame height in SVG user units.
*
* @return viewBox (or height attribute) height
*/
public double sourceHeight() {
return sourceHeight;
}

/**
* Returns the frame's width-to-height ratio for proportional sizing.
*
* @return {@code sourceWidth() / sourceHeight()}
*/
public double aspectRatio() {
return sourceWidth / sourceHeight;
}

/**
* One drawable layer: normalized geometry plus its resolved paint.
*
* @param geometry normalized path geometry (shared icon frame)
* @param fill fill colour, or {@code null} for no fill
* @param stroke outline stroke, or {@code null} for no stroke
* @since 1.8.0
*/
public record Layer(SvgPath geometry, DocumentColor fill, DocumentStroke stroke) {
/**
* Validates the geometry reference.
*/
public Layer {
Objects.requireNonNull(geometry, "geometry");
}
}
}
Loading