Skip to content

Commit dc95207

Browse files
committed
feat(svg): SvgIcon whole-file reader + addSvgIcon DSL stacking
SvgIcon.read(file)/parse(xml) reads the practical icon subset of an SVG document: every path plus rect/circle/ellipse/line/polyline/polygon lowered to synthesized path data through the one tested parser, g-nesting with translate/scale/rotate/matrix transforms accumulated as exact affines on Bezier control points (via a package-private SvgPath.parseTransformed hook), and fill/stroke/stroke-width styling with SVG inheritance and defaults (missing fill = black, none skips, style attribute wins). addSvgIcon(icon, width) stacks the ordered layers back-to-front through LayerStack. Security: DOCTYPE refused (XXE cannot reach the file system). Out of scope by design: gradients, CSS, text, filters. 11 SvgIconTest cases incl. exact transform math, inheritance, XXE refusal and the DSL bridge; example ships a two-tone badge via SvgIcon.parse. Full gate: see below.
1 parent 21e8723 commit dc95207

9 files changed

Lines changed: 764 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,15 @@ Entries land here as they merge.
7676
`PathBuilder.svg(svgPath)` drops the result straight into `addPath(...)`:
7777
any icon's `d` string renders as native PDF curves, no tessellation.
7878
Syntax errors report the character position; fills keep SVG's default
79-
non-zero winding rule.
79+
non-zero winding rule. On top of it, `SvgIcon.read(file)` / `parse(xml)`
80+
reads the practical subset of a whole SVG file — every `<path>` plus
81+
`rect` / `circle` / `ellipse` / `line` / `polyline` / `polygon` lowered to
82+
path data, `<g>` nesting with `translate` / `scale` / `rotate` / `matrix`
83+
transforms (affine maps are exact on Bézier control points), and
84+
`fill` / `stroke` / `stroke-width` styling with SVG inheritance and
85+
defaults — into ordered layers, and `addSvgIcon(icon, width)` stacks them
86+
back-to-front on the page. The XML reader refuses DOCTYPEs (no XXE);
87+
gradients, CSS, text and filters stay deliberately out of scope.
8088
- **Inline sparklines** (`@since 1.8.0`). `RichText.sparkline(w, h, color,
8189
values...)` draws a filled mini-area silhouette on the text baseline, and
8290
`sparklineLine(w, h, thickness, color, values...)` a constant-thickness line
304 Bytes
Binary file not shown.

examples/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -364,8 +364,9 @@ operators — perfectly smooth at any zoom, no tessellation. Coordinates
364364
are normalized to the shape's box (`(0,0)` bottom-left, `y` up) and
365365
control points may overshoot it. Strokes can be dashed via
366366
`dashed(on, off, ...)` — the pattern follows the curve. SVG icons drop in
367-
through `SvgPath.parse(d, viewBox...)` + `.svg(...)` — the full path
368-
grammar (arcs included) lands as native curves.
367+
through `SvgPath.parse(d, viewBox...)` + `.svg(...)`, or whole files via
368+
`SvgIcon.read(file)` + `addSvgIcon(icon, width)` — multi-layer icons with
369+
group transforms and per-layer paints, all as native curves.
369370

370371
```java
371372
flow.addPath(path -> path

examples/src/main/java/com/demcha/examples/features/shapes/VectorPathExample.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import com.demcha.compose.document.style.DocumentColor;
66
import com.demcha.compose.document.style.DocumentInsets;
77
import com.demcha.compose.document.style.DocumentStroke;
8+
import com.demcha.compose.document.svg.SvgIcon;
89
import com.demcha.compose.document.svg.SvgPath;
910
import com.demcha.examples.support.ExampleOutputPaths;
1011

@@ -45,6 +46,14 @@ public final class VectorPathExample {
4546
+ "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"
4647
+ "c0 3.78-3.4 6.86-8.55 11.54L12 21.35z";
4748

49+
/** Inline two-tone badge: tinted disc behind the Material heart. */
50+
private static final String TWO_TONE_BADGE_SVG = """
51+
<svg viewBox="0 0 24 24">
52+
<circle cx="12" cy="12" r="11" fill="#fde9e3"/>
53+
<path fill="#c41e3a" d="%s"/>
54+
</svg>
55+
""".formatted(MATERIAL_HEART_D);
56+
4857
private VectorPathExample() {
4958
}
5059

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

6170
try (DocumentSession document = GraphCompose.document(pdfFile)
62-
.pageSize(420, 660)
71+
.pageSize(420, 780)
6372
.margin(DocumentInsets.of(28))
6473
.create()) {
6574
document.pageFlow(page -> page
@@ -100,6 +109,8 @@ public static Path generate() throws Exception {
100109
.svg(SvgPath.parse(MATERIAL_HEART_D, 0, 0, 24, 24))
101110
.fillColor(DocumentColor.rgb(196, 30, 58))
102111
.margin(DocumentInsets.bottom(16)))
112+
.addParagraph("Whole-file icon — SvgIcon.read/parse stacks every layer")
113+
.addSvgIcon(SvgIcon.parse(TWO_TONE_BADGE_SVG), 64)
103114
.addParagraph("Mixed ribbon — lines and curves in one closed, filled subpath")
104115
.addPath(path -> path
105116
.name("Ribbon")

src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,33 @@ public T addPath(Consumer<PathBuilder> spec) {
543543
return add(BuilderSupport.configure(new PathBuilder(), spec).build());
544544
}
545545

546+
/**
547+
* Adds a multi-layer SVG icon at the given width, keeping the icon's
548+
* aspect ratio. Layers stack back-to-front exactly as in the source
549+
* file; every curve renders as native PDF geometry.
550+
*
551+
* @param icon parsed SVG icon
552+
* @param width target icon width in points
553+
* @return this builder
554+
* @since 1.8.0
555+
*/
556+
public T addSvgIcon(com.demcha.compose.document.svg.SvgIcon icon, double width) {
557+
Objects.requireNonNull(icon, "icon");
558+
double height = width / icon.aspectRatio();
559+
return addLayerStack(stack -> {
560+
for (int i = 0; i < icon.layers().size(); i++) {
561+
com.demcha.compose.document.svg.SvgIcon.Layer layer = icon.layers().get(i);
562+
stack.layer(new PathBuilder()
563+
.name("SvgLayer" + i)
564+
.size(width, height)
565+
.svg(layer.geometry())
566+
.fillColor(layer.fill())
567+
.stroke(layer.stroke())
568+
.build());
569+
}
570+
});
571+
}
572+
546573
/**
547574
* Adds a filled circle ellipse — shortcut for
548575
* {@code addEllipse(e -> e.circle(diameter).fillColor(fillColor))}.
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package com.demcha.compose.document.svg;
2+
3+
import com.demcha.compose.document.style.DocumentColor;
4+
import com.demcha.compose.document.style.DocumentStroke;
5+
6+
import java.io.IOException;
7+
import java.nio.charset.StandardCharsets;
8+
import java.nio.file.Files;
9+
import java.nio.file.Path;
10+
import java.util.List;
11+
import java.util.Objects;
12+
13+
/**
14+
* A multi-layer vector icon read from the practical subset of an SVG file:
15+
* the {@code viewBox}, every {@code <path>} (plus {@code rect}, {@code
16+
* circle}, {@code ellipse}, {@code line}, {@code polyline} and {@code
17+
* polygon}, lowered to path data), {@code <g>} nesting with accumulated
18+
* {@code transform} attributes ({@code translate} / {@code scale} /
19+
* {@code rotate} / {@code matrix} — affine maps are exact on Bézier control
20+
* points), and per-element {@code fill} / {@code stroke} /
21+
* {@code stroke-width} styling with SVG's inheritance and defaults
22+
* (missing {@code fill} paints black, {@code fill="none"} skips the fill).
23+
*
24+
* <p>Each layer is one {@link SvgPath} with its resolved paint, in document
25+
* order — render them back-to-front. The DSL does exactly that:
26+
* {@code flow.addSvgIcon(icon, 48)} stacks the layers into the page at the
27+
* requested width with the icon's own aspect ratio.</p>
28+
*
29+
* <p>Out of scope (deliberately, this is an icon reader, not a browser):
30+
* gradients, CSS stylesheets and classes, text, masks, filters,
31+
* {@code <use>} references, and animations. The XML reader refuses
32+
* DOCTYPEs, so external-entity tricks cannot reach the file system.</p>
33+
*
34+
* <pre>{@code
35+
* SvgIcon logo = SvgIcon.read(Path.of("assets/logo.svg"));
36+
* flow.addSvgIcon(logo, 48);
37+
* }</pre>
38+
*
39+
* @author Artem Demchyshyn
40+
* @since 1.8.0
41+
*/
42+
public final class SvgIcon {
43+
44+
private final List<Layer> layers;
45+
private final double sourceWidth;
46+
private final double sourceHeight;
47+
48+
SvgIcon(List<Layer> layers, double sourceWidth, double sourceHeight) {
49+
this.layers = List.copyOf(layers);
50+
this.sourceWidth = sourceWidth;
51+
this.sourceHeight = sourceHeight;
52+
}
53+
54+
/**
55+
* Reads and parses an SVG file.
56+
*
57+
* @param file path to the SVG file
58+
* @return parsed icon
59+
* @throws IOException if the file cannot be read
60+
* @throws IllegalArgumentException if the document is not parseable SVG,
61+
* has no viewBox or usable size, or
62+
* contains no drawable geometry
63+
*/
64+
public static SvgIcon read(Path file) throws IOException {
65+
Objects.requireNonNull(file, "file");
66+
return parse(Files.readString(file, StandardCharsets.UTF_8));
67+
}
68+
69+
/**
70+
* Parses SVG markup.
71+
*
72+
* @param svgXml the SVG document text
73+
* @return parsed icon
74+
* @throws IllegalArgumentException if the document is not parseable SVG,
75+
* has no viewBox or usable size, or
76+
* contains no drawable geometry
77+
*/
78+
public static SvgIcon parse(String svgXml) {
79+
return SvgIconReader.read(svgXml);
80+
}
81+
82+
/**
83+
* Returns the icon's layers in document order (paint back-to-front).
84+
*
85+
* @return immutable layer list; never empty
86+
*/
87+
public List<Layer> layers() {
88+
return layers;
89+
}
90+
91+
/**
92+
* Returns the icon frame width in SVG user units.
93+
*
94+
* @return viewBox (or width attribute) width
95+
*/
96+
public double sourceWidth() {
97+
return sourceWidth;
98+
}
99+
100+
/**
101+
* Returns the icon frame height in SVG user units.
102+
*
103+
* @return viewBox (or height attribute) height
104+
*/
105+
public double sourceHeight() {
106+
return sourceHeight;
107+
}
108+
109+
/**
110+
* Returns the frame's width-to-height ratio for proportional sizing.
111+
*
112+
* @return {@code sourceWidth() / sourceHeight()}
113+
*/
114+
public double aspectRatio() {
115+
return sourceWidth / sourceHeight;
116+
}
117+
118+
/**
119+
* One drawable layer: normalized geometry plus its resolved paint.
120+
*
121+
* @param geometry normalized path geometry (shared icon frame)
122+
* @param fill fill colour, or {@code null} for no fill
123+
* @param stroke outline stroke, or {@code null} for no stroke
124+
* @since 1.8.0
125+
*/
126+
public record Layer(SvgPath geometry, DocumentColor fill, DocumentStroke stroke) {
127+
/**
128+
* Validates the geometry reference.
129+
*/
130+
public Layer {
131+
Objects.requireNonNull(geometry, "geometry");
132+
}
133+
}
134+
}

0 commit comments

Comments
 (0)