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 zqHkz6YXINCHATmcU3G&#dj7rII6H>@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 } references, and animations. The XML reader refuses + * DOCTYPEs, so external-entity tricks cannot reach the file system.

+ * + *
{@code
+ * SvgIcon logo = SvgIcon.read(Path.of("assets/logo.svg"));
+ * flow.addSvgIcon(logo, 48);
+ * }
+ * + * @author Artem Demchyshyn + * @since 1.8.0 + */ +public final class SvgIcon { + + private final List layers; + private final double sourceWidth; + private final double sourceHeight; + + SvgIcon(List 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 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"); + } + } +} diff --git a/src/main/java/com/demcha/compose/document/svg/SvgIconReader.java b/src/main/java/com/demcha/compose/document/svg/SvgIconReader.java new file mode 100644 index 000000000..f5a1ead0e --- /dev/null +++ b/src/main/java/com/demcha/compose/document/svg/SvgIconReader.java @@ -0,0 +1,364 @@ +package com.demcha.compose.document.svg; + +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentStroke; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.InputSource; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.StringReader; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * Internal DOM walker behind {@link SvgIcon#parse(String)}: secure XML setup + * (DOCTYPE refused, so XXE cannot reach the file system), viewBox + * resolution, recursive {@code } traversal with affine accumulation and + * paint inheritance, shape-to-path lowering (every basic shape becomes + * synthesized path data fed through the one tested parser), and the icon + * colour subset ({@code #rgb}, {@code #rrggbb}, {@code rgb(r,g,b)}, + * {@code none}, {@code currentColor} → default ink). + */ +final class SvgIconReader { + + private SvgIconReader() { + } + + static SvgIcon read(String svgXml) { + Element root = parseXml(svgXml); + if (!"svg".equals(localName(root))) { + throw new IllegalArgumentException("not an SVG document: root element is <" + root.getNodeName() + ">"); + } + double[] box = viewBox(root); + + List layers = new ArrayList<>(); + walk(root, identity(), new Paint(DocumentColor.rgb(0, 0, 0), null, 1.0), box, layers); + if (layers.isEmpty()) { + throw new IllegalArgumentException("SVG document contains no drawable geometry"); + } + return new SvgIcon(layers, box[2], box[3]); + } + + // ------------------------------------------------------------------ + // XML + // ------------------------------------------------------------------ + + private static Element parseXml(String svgXml) { + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + Document document = factory.newDocumentBuilder() + .parse(new InputSource(new StringReader(svgXml == null ? "" : svgXml))); + return document.getDocumentElement(); + } catch (Exception e) { + throw new IllegalArgumentException("not parseable SVG: " + e.getMessage(), e); + } + } + + private static String localName(Element element) { + String name = element.getNodeName(); + int colon = name.indexOf(':'); + return colon < 0 ? name : name.substring(colon + 1); + } + + private static double[] viewBox(Element svg) { + String viewBox = svg.getAttribute("viewBox").trim(); + if (!viewBox.isEmpty()) { + String[] parts = viewBox.split("[\\s,]+"); + if (parts.length != 4) { + throw new IllegalArgumentException("viewBox must carry four numbers: '" + viewBox + "'"); + } + double minX = Double.parseDouble(parts[0]); + double minY = Double.parseDouble(parts[1]); + double width = Double.parseDouble(parts[2]); + double height = Double.parseDouble(parts[3]); + requirePositive(width, height, viewBox); + return new double[]{minX, minY, width, height}; + } + String w = svg.getAttribute("width").replace("px", "").trim(); + String h = svg.getAttribute("height").replace("px", "").trim(); + if (w.isEmpty() || h.isEmpty()) { + throw new IllegalArgumentException("SVG carries neither a viewBox nor width/height attributes"); + } + double width = Double.parseDouble(w); + double height = Double.parseDouble(h); + requirePositive(width, height, w + " x " + h); + return new double[]{0, 0, width, height}; + } + + private static void requirePositive(double width, double height, String source) { + if (!(width > 0) || !(height > 0)) { + throw new IllegalArgumentException("SVG frame must be positive: " + source); + } + } + + // ------------------------------------------------------------------ + // Tree walk + // ------------------------------------------------------------------ + + /** Inherited paint state: SVG fills default to black, strokes to none. */ + private record Paint(DocumentColor fill, DocumentColor strokeColor, double strokeWidth) { + } + + private static void walk(Element element, double[] transform, Paint inherited, + double[] box, List out) { + Paint paint = stylize(element, inherited); + double[] matrix = compose(transform, element.getAttribute("transform")); + + String name = localName(element); + String d = switch (name) { + case "path" -> element.getAttribute("d"); + case "rect" -> rectToPath(element); + case "circle" -> ellipseToPath(num(element, "cx"), num(element, "cy"), + num(element, "r"), num(element, "r")); + case "ellipse" -> ellipseToPath(num(element, "cx"), num(element, "cy"), + num(element, "rx"), num(element, "ry")); + case "line" -> "M" + num(element, "x1") + " " + num(element, "y1") + + " L" + num(element, "x2") + " " + num(element, "y2"); + case "polyline" -> pointsToPath(element.getAttribute("points"), false); + case "polygon" -> pointsToPath(element.getAttribute("points"), true); + default -> null; + }; + + if (d != null && !d.isBlank()) { + DocumentStroke stroke = paint.strokeColor() == null || paint.strokeWidth() <= 0 + ? null + : DocumentStroke.of(paint.strokeColor(), paint.strokeWidth()); + if (paint.fill() != null || stroke != null) { + SvgPath geometry = SvgPath.parseTransformed(d, matrix, box[0], box[1], box[2], box[3]); + out.add(new SvgIcon.Layer(geometry, paint.fill(), stroke)); + } + } + + // Containers (svg, g, unknown wrappers) recurse; defs and metadata + // subtrees carry no direct geometry and are skipped wholesale. + if (name.equals("svg") || name.equals("g")) { + NodeList children = element.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + Node child = children.item(i); + if (child instanceof Element childElement) { + walk(childElement, matrix, paint, box, out); + } + } + } + } + + // ------------------------------------------------------------------ + // Styling + // ------------------------------------------------------------------ + + private static Paint stylize(Element element, Paint inherited) { + DocumentColor fill = inherited.fill(); + DocumentColor strokeColor = inherited.strokeColor(); + double strokeWidth = inherited.strokeWidth(); + + String fillAttr = attrOrStyle(element, "fill"); + if (fillAttr != null) { + fill = color(fillAttr, inherited.fill()); + } + String strokeAttr = attrOrStyle(element, "stroke"); + if (strokeAttr != null) { + strokeColor = color(strokeAttr, inherited.strokeColor()); + } + String widthAttr = attrOrStyle(element, "stroke-width"); + if (widthAttr != null) { + strokeWidth = Double.parseDouble(widthAttr.replace("px", "").trim()); + } + return new Paint(fill, strokeColor, strokeWidth); + } + + private static String attrOrStyle(Element element, String property) { + String attr = element.getAttribute(property).trim(); + if (!attr.isEmpty()) { + return attr; + } + String style = element.getAttribute("style"); + for (String declaration : style.split(";")) { + int colon = declaration.indexOf(':'); + if (colon > 0 && declaration.substring(0, colon).trim().equals(property)) { + return declaration.substring(colon + 1).trim(); + } + } + return null; + } + + private static DocumentColor color(String value, DocumentColor current) { + String v = value.trim().toLowerCase(Locale.ROOT); + if (v.equals("none")) { + return null; + } + if (v.equals("currentcolor") || v.equals("inherit")) { + return current; + } + if (v.startsWith("#")) { + String hex = v.substring(1); + if (hex.length() == 3) { + hex = "" + hex.charAt(0) + hex.charAt(0) + + hex.charAt(1) + hex.charAt(1) + + hex.charAt(2) + hex.charAt(2); + } + if (hex.length() == 6) { + return DocumentColor.rgb( + Integer.parseInt(hex.substring(0, 2), 16), + Integer.parseInt(hex.substring(2, 4), 16), + Integer.parseInt(hex.substring(4, 6), 16)); + } + } + if (v.startsWith("rgb(") && v.endsWith(")")) { + String[] parts = v.substring(4, v.length() - 1).split(","); + if (parts.length == 3) { + return DocumentColor.rgb( + Integer.parseInt(parts[0].trim()), + Integer.parseInt(parts[1].trim()), + Integer.parseInt(parts[2].trim())); + } + } + if (v.equals("black")) { + return DocumentColor.rgb(0, 0, 0); + } + if (v.equals("white")) { + return DocumentColor.rgb(255, 255, 255); + } + throw new IllegalArgumentException( + "unsupported SVG colour '" + value + "' — use #hex, rgb(r,g,b), none, or currentColor"); + } + + // ------------------------------------------------------------------ + // Shape lowering (synthesized path data through the tested parser) + // ------------------------------------------------------------------ + + private static String rectToPath(Element rect) { + double x = num(rect, "x"); + double y = num(rect, "y"); + double w = num(rect, "width"); + double h = num(rect, "height"); + double rx = num(rect, "rx"); + double ry = num(rect, "ry"); + if (rx <= 0 && ry <= 0) { + return "M" + x + " " + y + " h" + w + " v" + h + " h" + (-w) + " Z"; + } + if (rx <= 0) { + rx = ry; + } + if (ry <= 0) { + ry = rx; + } + rx = Math.min(rx, w / 2); + ry = Math.min(ry, h / 2); + return "M" + (x + rx) + " " + y + + " h" + (w - 2 * rx) + + " a" + rx + " " + ry + " 0 0 1 " + rx + " " + ry + + " v" + (h - 2 * ry) + + " a" + rx + " " + ry + " 0 0 1 " + (-rx) + " " + ry + + " h" + (2 * rx - w) + + " a" + rx + " " + ry + " 0 0 1 " + (-rx) + " " + (-ry) + + " v" + (2 * ry - h) + + " a" + rx + " " + ry + " 0 0 1 " + rx + " " + (-ry) + + " Z"; + } + + private static String ellipseToPath(double cx, double cy, double rx, double ry) { + if (rx <= 0 || ry <= 0) { + return null; + } + return "M" + (cx - rx) + " " + cy + + " a" + rx + " " + ry + " 0 1 0 " + (2 * rx) + " 0" + + " a" + rx + " " + ry + " 0 1 0 " + (-2 * rx) + " 0" + + " Z"; + } + + private static String pointsToPath(String points, boolean close) { + String trimmed = points == null ? "" : points.trim(); + if (trimmed.isEmpty()) { + return null; + } + return "M" + trimmed + (close ? " Z" : ""); + } + + private static double num(Element element, String attribute) { + String value = element.getAttribute(attribute).trim(); + return value.isEmpty() ? 0.0 : Double.parseDouble(value); + } + + // ------------------------------------------------------------------ + // Transforms + // ------------------------------------------------------------------ + + private static double[] identity() { + return new double[]{1, 0, 0, 1, 0, 0}; + } + + /** Composes {@code transform="…"} ops onto the parent matrix, left to right. */ + private static double[] compose(double[] parent, String transformAttribute) { + String attr = transformAttribute == null ? "" : transformAttribute.trim(); + if (attr.isEmpty()) { + return parent; + } + double[] m = parent; + int index = 0; + while (index < attr.length()) { + int open = attr.indexOf('(', index); + if (open < 0) { + break; + } + int closeParen = attr.indexOf(')', open); + if (closeParen < 0) { + throw new IllegalArgumentException("unterminated transform: '" + attr + "'"); + } + String op = attr.substring(index, open).replace(",", " ").trim(); + String[] args = attr.substring(open + 1, closeParen).trim().split("[\\s,]+"); + m = multiply(m, transformOp(op, args, attr)); + index = closeParen + 1; + while (index < attr.length() + && (attr.charAt(index) == ' ' || attr.charAt(index) == ',')) { + index++; + } + } + return m; + } + + private static double[] transformOp(String op, String[] args, String source) { + double[] v = new double[args.length]; + for (int i = 0; i < args.length; i++) { + v[i] = Double.parseDouble(args[i]); + } + return switch (op) { + case "translate" -> new double[]{1, 0, 0, 1, v[0], v.length > 1 ? v[1] : 0}; + case "scale" -> new double[]{v[0], 0, 0, v.length > 1 ? v[1] : v[0], 0, 0}; + case "matrix" -> new double[]{v[0], v[1], v[2], v[3], v[4], v[5]}; + case "rotate" -> { + double radians = Math.toRadians(v[0]); + double cos = Math.cos(radians); + double sin = Math.sin(radians); + double[] rotation = {cos, sin, -sin, cos, 0, 0}; + if (v.length == 3) { + double[] toOrigin = {1, 0, 0, 1, -v[1], -v[2]}; + double[] back = {1, 0, 0, 1, v[1], v[2]}; + yield multiply(multiply(back, rotation), toOrigin); + } + yield rotation; + } + default -> throw new IllegalArgumentException( + "unsupported transform '" + op + "' in '" + source + "'"); + }; + } + + /** SVG matrix composition: result = a × b (b applies first). */ + private static double[] multiply(double[] a, double[] b) { + return new double[]{ + a[0] * b[0] + a[2] * b[1], + a[1] * b[0] + a[3] * b[1], + a[0] * b[2] + a[2] * b[3], + a[1] * b[2] + a[3] * b[3], + a[0] * b[4] + a[2] * b[5] + a[4], + a[1] * b[4] + a[3] * b[5] + a[5]}; + } +} diff --git a/src/main/java/com/demcha/compose/document/svg/SvgPath.java b/src/main/java/com/demcha/compose/document/svg/SvgPath.java index d8539d96c..637a4d21d 100644 --- a/src/main/java/com/demcha/compose/document/svg/SvgPath.java +++ b/src/main/java/com/demcha/compose/document/svg/SvgPath.java @@ -88,6 +88,27 @@ public static SvgPath parse(String d, double minX, double minY, double width, do return normalize(new SvgPathParser(d).parse(), minX, minY, width, height); } + /** + * Parses path data, applies an affine transform in SVG user space, and + * normalizes against the given viewBox. Affine maps are exact on cubic + * Bézier control points, so group transforms cost no precision. Used by + * {@link SvgIcon}; the transform is {@code [a, b, c, d, e, f]} with + * {@code x' = a·x + c·y + e}, {@code y' = b·x + d·y + f}. + */ + static SvgPath parseTransformed(String d, double[] affine, + double minX, double minY, double width, double height) { + List ops = new SvgPathParser(d).parse(); + for (double[] op : ops) { + for (int i = 1; i + 1 < op.length; i += 2) { + double x = op[i]; + double y = op[i + 1]; + op[i] = affine[0] * x + affine[2] * y + affine[4]; + op[i + 1] = affine[1] * x + affine[3] * y + affine[5]; + } + } + return normalize(ops, minX, minY, width, height); + } + /** * Returns the normalized segments (unit box, y-up), ready for * {@code PathBuilder.svg(...)} or a {@code PathNode}. diff --git a/src/test/java/com/demcha/compose/document/svg/SvgIconTest.java b/src/test/java/com/demcha/compose/document/svg/SvgIconTest.java new file mode 100644 index 000000000..aab065874 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/svg/SvgIconTest.java @@ -0,0 +1,194 @@ +package com.demcha.compose.document.svg; + +import com.demcha.compose.GraphCompose; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.layout.PlacedNode; +import com.demcha.compose.document.style.DocumentInsets; +import com.demcha.compose.document.style.DocumentPathSegment.CubicTo; +import com.demcha.compose.document.style.DocumentPathSegment.LineTo; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.within; + +/** + * Icon-reader coverage: layer order and paints, SVG defaults and + * inheritance, shape-to-path lowering, group transforms, viewBox handling, + * the security posture (DOCTYPE refused), and the DSL bridge. + */ +class SvgIconTest { + + @Test + void twoToneIconKeepsLayerOrderAndPaints() { + SvgIcon icon = SvgIcon.parse(""" + + + + + """); + + assertThat(icon.layers()).hasSize(2); + assertThat(icon.layers().get(0).fill().color()).isEqualTo(new java.awt.Color(253, 233, 227)); + assertThat(icon.layers().get(0).stroke()).isNull(); + SvgIcon.Layer top = icon.layers().get(1); + assertThat(top.fill().color()).isEqualTo(new java.awt.Color(196, 30, 58)); + assertThat(top.stroke()).isNotNull(); + assertThat(top.stroke().width()).isEqualTo(2.0); + assertThat(icon.aspectRatio()).isCloseTo(1.0, within(1e-9)); + } + + @Test + void missingFillPaintsBlackAndNoneSkipsTheFill() { + SvgIcon icon = SvgIcon.parse(""" + + + + + """); + + assertThat(icon.layers().get(0).fill().color()).isEqualTo(new java.awt.Color(0, 0, 0)); + assertThat(icon.layers().get(1).fill()).isNull(); + assertThat(icon.layers().get(1).stroke()).isNotNull(); + } + + @Test + void invisibleElementsProduceNoLayers() { + SvgIcon icon = SvgIcon.parse(""" + + + + + """); + + assertThat(icon.layers()).hasSize(1); + } + + @Test + void basicShapesLowerToPathGeometry() { + SvgIcon icon = SvgIcon.parse(""" + + + + + + + """); + + assertThat(icon.layers()).hasSize(4); + // The circle arrives as two arc-derived cubics. + assertThat(icon.layers().get(1).geometry().segments().stream() + .filter(CubicTo.class::isInstance).count()).isGreaterThanOrEqualTo(2); + // The polygon closes; the line stays open stroke-only geometry. + assertThat(icon.layers().get(3).geometry().segments().get(1)).isInstanceOf(LineTo.class); + } + + @Test + void groupTransformsAccumulateExactly() { + SvgIcon icon = SvgIcon.parse(""" + + + + + + """); + + // (0,0)→(2,2) and (4,0)→(10,2): normalized x = 10/20, y flips. + LineTo end = (LineTo) icon.layers().get(0).geometry().segments().get(1); + assertThat(end.x()).isCloseTo(0.5, within(1e-9)); + assertThat(end.y()).isCloseTo(1.0 - 2.0 / 20.0, within(1e-9)); + } + + @Test + void rotateAboutACenterMatchesTheSvgDefinition() { + SvgIcon icon = SvgIcon.parse(""" + + + + """); + + // rotate(90°, 10, 10) maps (10,0)→(20,10) and (20,0)→(20,20). + LineTo end = (LineTo) icon.layers().get(0).geometry().segments().get(1); + assertThat(end.x()).isCloseTo(1.0, within(1e-9)); + assertThat(end.y()).isCloseTo(0.0, within(1e-9)); + } + + @Test + void groupPaintInheritsAndStyleAttributeWins() { + SvgIcon icon = SvgIcon.parse(""" + + + + + + + """); + + assertThat(icon.layers().get(0).fill().color()).isEqualTo(new java.awt.Color(0, 170, 0)); + assertThat(icon.layers().get(1).fill().color()).isEqualTo(new java.awt.Color(0, 0, 170)); + } + + @Test + void widthHeightAttributesBackstopAMissingViewBox() { + SvgIcon icon = SvgIcon.parse(""" + + + + """); + + assertThat(icon.aspectRatio()).isCloseTo(2.0, within(1e-9)); + } + + @Test + void doctypeIsRefused() { + assertThatThrownBy(() -> SvgIcon.parse(""" + ]> + + """)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("not parseable"); + } + + @Test + void contractViolationsThrowWithContext() { + assertThatThrownBy(() -> SvgIcon.parse("")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("no drawable geometry"); + assertThatThrownBy(() -> SvgIcon.parse("
")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("root element"); + assertThatThrownBy(() -> SvgIcon.parse( + "")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unsupported SVG colour"); + assertThatThrownBy(() -> SvgIcon.parse( + "")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("unsupported transform"); + } + + @Test + void dslBridgeStacksLayersIntoTheFlow() throws Exception { + SvgIcon icon = SvgIcon.parse(""" + + + + + """); + + try (DocumentSession document = GraphCompose.document() + .pageSize(200, 200) + .margin(DocumentInsets.of(12)) + .create()) { + document.pageFlow(page -> page.addSvgIcon(icon, 48)); + + assertThat(document.layoutGraph().nodes()).extracting(PlacedNode::path) + .anyMatch(path -> path.contains("SvgLayer0")) + .anyMatch(path -> path.contains("SvgLayer1")); + byte[] pdf = document.toPdfBytes(); + assertThat(new String(pdf, 0, 5, StandardCharsets.US_ASCII)).isEqualTo("%PDF-"); + } + } +} From 7113e009599ee9d006396cd14b75e230e6a7394a Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 12 Jun 2026 10:18:57 +0100 Subject: [PATCH 2/3] docs(svg): mark SvgIcon and addSvgIcon @Beta --- .../com/demcha/compose/document/dsl/AbstractFlowBuilder.java | 1 + src/main/java/com/demcha/compose/document/svg/SvgIcon.java | 5 +++++ 2 files changed, 6 insertions(+) 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 71a19b61a..713e3c134 100644 --- a/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/AbstractFlowBuilder.java @@ -553,6 +553,7 @@ public T addPath(Consumer spec) { * @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(); diff --git a/src/main/java/com/demcha/compose/document/svg/SvgIcon.java b/src/main/java/com/demcha/compose/document/svg/SvgIcon.java index 6dfac1d5d..35e41c9bf 100644 --- a/src/main/java/com/demcha/compose/document/svg/SvgIcon.java +++ b/src/main/java/com/demcha/compose/document/svg/SvgIcon.java @@ -1,5 +1,6 @@ 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; @@ -36,9 +37,13 @@ * flow.addSvgIcon(logo, 48); * } * + *

Beta: the SVG surface is new in 1.8.0 and marked {@link Beta} + * while it hardens against real-world exporter output.

+ * * @author Artem Demchyshyn * @since 1.8.0 */ +@Beta public final class SvgIcon { private final List layers; From 740db51801f12fa99b982083563a2aebf66f3e09 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 12 Jun 2026 10:40:24 +0100 Subject: [PATCH 3/3] review: svg workstream fixes - import order, silent-loss docs (clip paths, nested svg) --- .../java/com/demcha/compose/document/dsl/PathBuilder.java | 2 +- src/main/java/com/demcha/compose/document/svg/SvgIcon.java | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) 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 eb347bea5..52a338511 100644 --- a/src/main/java/com/demcha/compose/document/dsl/PathBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/PathBuilder.java @@ -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; diff --git a/src/main/java/com/demcha/compose/document/svg/SvgIcon.java b/src/main/java/com/demcha/compose/document/svg/SvgIcon.java index 35e41c9bf..8c463dffd 100644 --- a/src/main/java/com/demcha/compose/document/svg/SvgIcon.java +++ b/src/main/java/com/demcha/compose/document/svg/SvgIcon.java @@ -28,8 +28,10 @@ * 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 } references, and animations. The XML reader refuses + * gradients, CSS stylesheets and classes, text, masks, clip paths, + * filters, {@code } references, nested {@code } 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.

* *
{@code