From dc952075896c6ee4406efe1769a72ad87bba7a8a Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Fri, 12 Jun 2026 10:01:16 +0100
Subject: [PATCH 1/3] 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.
---
CHANGELOG.md | 10 +-
assets/readme/examples/vector-path.pdf | Bin 1609 -> 1913 bytes
examples/README.md | 5 +-
.../features/shapes/VectorPathExample.java | 13 +-
.../document/dsl/AbstractFlowBuilder.java | 27 ++
.../demcha/compose/document/svg/SvgIcon.java | 134 +++++++
.../compose/document/svg/SvgIconReader.java | 364 ++++++++++++++++++
.../demcha/compose/document/svg/SvgPath.java | 21 +
.../compose/document/svg/SvgIconTest.java | 194 ++++++++++
9 files changed, 764 insertions(+), 4 deletions(-)
create mode 100644 src/main/java/com/demcha/compose/document/svg/SvgIcon.java
create mode 100644 src/main/java/com/demcha/compose/document/svg/SvgIconReader.java
create mode 100644 src/test/java/com/demcha/compose/document/svg/SvgIconTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 750482562..f7916d71e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -76,7 +76,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 `` plus
+ `rect` / `circle` / `ellipse` / `line` / `polyline` / `polygon` lowered to
+ path data, `` 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
diff --git a/assets/readme/examples/vector-path.pdf b/assets/readme/examples/vector-path.pdf
index a9e402d92014bdd46e0f7da0aeb81ea938dcec02..5ae1744260688075b5a189a55c7c604b774328ed 100644
GIT binary patch
delta 1535
zcmbu({Xf$Q0KoBK!{*__mOLc$)Ixl1HeU~ueaE_+L^)K*Nh=YBqB?6*wks(aDi3j(
zwU}GcJg&&IL>ls}rKAEFUP4ruYi7hIE70{WSR(G9z~
zkNkQ5a=!au^$zxZ;ul@H2y8te)SNULwjFhTy$?#%K0+#8nM+Nxw$j#uR@!~i6
z{GBbT2`sx}6IJ}lC*TGkxl5wP)5kY$P~-7;-s`WMy=IgZo8L1!hA;k$f8W@XY)e)P
zqHkz6YXINCHATmcU3Gdj7rII6H>@uxlA@G3%1o8HcR*MPA#hd{?3#f4-jNZP-XK
z{8|aI7*ApP1B%l`f3@;fp%n9Q>W2T5u+R}COIae*Vr#B4ESZT-(hb^e^OZTx<63;k
z;x-6gYzhcpKf9=f3mBl9%F$h
zcHX7Mnq0KQDnm22(m_mrIVBds!SI}UDfXKb27LHyw{!RSY>?4ewY5UVF?VDs$$#V{y*kh%QPf}NePFNfrCHZ}
znztlJP%%>RvJ&nrx-!$EbEjOA&$>;I3(6z(ph5faQwltED#DL?2~5sa!l0}IF(-7d
z)EA7UHRiiKu3fjuDt(YXkL4!5N**8MnDZmz2^hELr{yTliG^UVl8cs8u?A1-Q=~IR
zP1Lkx-`{b%NWu3+bv(_qCi=%k8bzUG9b-Kcm3tbyI{4rb@jB(;Eldn6ZD(+prGE6X
z*5!87+&mn(ThpzsF>Fqw0vRhG_Y`M(_LyzXjsvp_OAHnB`eCQZ6^f>vZg$Zl9Lrkfq0?}nr9}7L%Sbo(*M~x@jmkD_qY$OenZyCn
z(oJN8Qq)uw+n0RTOFYMF^PS9izS*s!aAgP+euMQSmSeKU69iQ*Z<%;6-Cficz0tM3
z@J7b9iu1xdHb45shDSD{p-4%l4hK5E5JB&m%M4B4+T|QnOL3P`(HIli0&!9%vAo{w%)=b10{5wm3nJtn6%^M{UX42{hmo=2ewBk*
z!>D+tO$h-L4wQG(4-f_gXbL=#qB)lE!9eMuP6f$j5
zA^=j}OfW)b2gJzO^=1RPl9MWJwHE06PL+B+Ft+ttOuTAd+S$S!oFqZPXDEhzxazR2
zujOpXuIxN$3wbGtS$8}+l-O5E0l^O|b-j?zyaJH<#cT@3P2BtL?45$k%BelHc5ZeB
zxj{cuyKG-lIB(iXQ_$Pi@{34eRDWPkD;F!T!IeeeSfCY^gs0j9wg8PvC0Id35TXG@
zk_|+%hKNKO)dlZDBG^E-5S~J_rdm@-6ab)-h5>nSBN;NSOk-U
WnQ6;B{CTE_B7!88v9XIg4fQWp0lqZ=
delta 1227
zcmbu)jX#qK0Kjot-bPG}YNaT$PGgU~mUcbZyk%bVT8qjhudPu^wM3;iFFo=$6UT^4
zF-=07w~ubh$|*1NHreIGDYu>KY~0JgaR0#f^ZR`>6dUA3!bpx}$UtX1n|lz1ICz8S
z%fVlV8G+$V*0I+jt2o}xYdEL@7f!;-qNwOr-mf!?<1=1DJ6|&!9XEcQmygeH0V~?1tHcqWVwQAwi^7Pr@#Zz1Tm6K}A3j;TUqDI8`a5W{n620%=
z@(B1)xhs^qX=gwPaw!X9o5pu$qCX?O??(M}l~`Z8OwR7V$)@TjjLQllQH+sGs4t?7
z-zh2tO
zKP0t~rRU1jWi&wIZ9E9U^4&vX6}7FRa9dc|Qj=}~_B!9%lJzs3`7pHqb*8NN_8-5P
zR|(^$3_~S{*kk8(6T9BkqIu??tL13qJWIB6Z<3!iPi@2U>LcCSc2}f*TvsG}Jo;6X
z!u3-Zd^RC%II?YYsG?=?1~ic5-v{ys&?U(Iqacq2Lr
z_5M%!2gU12kxH3AAyTEVmyH4~#sbH}=%(6l^ocwgrXJNEM;n&=>uLO`Z1r6v0vVSz
zkyfbsSo#5CB_z$|IS7vNRUv)dh{B1?Y`f|vz^EMW&p5aC8*;d;h34aa#~&=SZNQ*<
z+46Vy={1z*CjD7=+5D4a#+jnVy19;aCC|4zPtRom+IS*o!Qo_LkdxS^J6nDCvS!r_
zG|Dc5i5;~#5j%aFAWyhpzhZSoQd8%}b=#Novahfmdcpu~D)vx-4=Z1SKXnv77Ehei
z^};+lA!bAy^G{Vqpk4C&XOqp#Tn6-EQn5s~e&i|~|KKgOsUv4*mHvj2#78Sl3L=c#
z4{mOeJew0VuIpU5ra;$b^bfp|nm6e1f-1Nu@{YzOBZ8SU<@?JcB}ZmquP}S)L(F_b
zz_)+H)Xs2bYNlV)y6C#FagTTD692lq+^rEtLQRX&13mwJ0NS=}A{FmS!-Eb~JU{~h
zfaFSc1_>mh3+M7OzSI6wm6*49)X8vJi>SvOk%
diff --git a/examples/README.md b/examples/README.md
index 354243614..c32d4b86c 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -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
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 8f6d6a8d9..c570beb1d 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.SvgIcon;
import com.demcha.compose.document.svg.SvgPath;
import com.demcha.examples.support.ExampleOutputPaths;
@@ -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 = """
+
+ """.formatted(MATERIAL_HEART_D);
+
private VectorPathExample() {
}
@@ -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
@@ -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")
diff --git a/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java b/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java
index 0bbeaf123..71a19b61a 100644
--- a/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java
+++ b/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java
@@ -543,6 +543,33 @@ public T addPath(Consumer 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
+ */
+ 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))}.
diff --git a/src/main/java/com/demcha/compose/document/svg/SvgIcon.java b/src/main/java/com/demcha/compose/document/svg/SvgIcon.java
new file mode 100644
index 000000000..6dfac1d5d
--- /dev/null
+++ b/src/main/java/com/demcha/compose/document/svg/SvgIcon.java
@@ -0,0 +1,134 @@
+package com.demcha.compose.document.svg;
+
+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 } (plus {@code rect}, {@code
+ * circle}, {@code ellipse}, {@code line}, {@code polyline} and {@code
+ * polygon}, lowered to path data), {@code } 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).
+ *
+ *
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.
+ *
+ *
Out of scope (deliberately, this is an icon reader, not a browser):
+ * gradients, CSS stylesheets and classes, text, masks, filters,
+ * {@code