From 2a2f54eca77fe3c6f3175630b6d0a6946975e134 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 13 Jun 2026 10:38:04 +0100 Subject: [PATCH 1/2] feat(svg): every reader error names the offending element and why MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accepting a real-world SVG that won't render now explains itself instead of a cryptic message: - each per-element error is wrapped with element context — the deepest failing element (not its wrapping ), its attributes (long values like d truncated), then the original reason and the supported set: 'in : unsupported SVG colour … — use #hex, rgb()/rgba(), a CSS colour name, …' - a blank result names what was dropped: 'no drawable geometry — skipped text; this reader renders vector shapes only', not a bare 'no geometry' - shape lowering (rect/ellipse/points → path data) extracted to SvgShapeLowering (this change crossed 500 LOC; back to 469) with its own 4-test unit; the reader's per-element try wraps once, recursion stays outside so child errors never double-wrap +8 tests (4 error-context in SvgIconTest, 4 in SvgShapeLoweringTest) --- CHANGELOG.md | 8 +- .../compose/document/svg/SvgIconReader.java | 205 +++++++++--------- .../document/svg/SvgShapeLowering.java | 82 +++++++ .../compose/document/svg/SvgIconTest.java | 61 ++++++ .../document/svg/SvgShapeLoweringTest.java | 49 +++++ 5 files changed, 304 insertions(+), 101 deletions(-) create mode 100644 src/main/java/com/demcha/compose/document/svg/SvgShapeLowering.java create mode 100644 src/test/java/com/demcha/compose/document/svg/SvgShapeLoweringTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 07796361..fc98d89a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,7 +115,13 @@ Entries land here as they merge. `shape`, …) it drops. The XML reader refuses DOCTYPEs (no XXE); CSS stylesheets, text, filters, focal radials, non-pad `spreadMethod` and translucent gradient stops stay deliberately out of scope — the reader - fails loudly rather than rendering them wrong. + fails loudly rather than rendering them wrong. **Every reader error names + the offending element and why**: an unsupported colour / transform / + gradient / unit is reported as `in : `, pinpointing the deepest failing element (not its wrapping + ``); a blank result explains itself (`no drawable geometry — skipped + text; this reader renders vector shapes only`) instead of a bare "no + geometry". - **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/src/main/java/com/demcha/compose/document/svg/SvgIconReader.java b/src/main/java/com/demcha/compose/document/svg/SvgIconReader.java index 20029e4f..958e4f1c 100644 --- a/src/main/java/com/demcha/compose/document/svg/SvgIconReader.java +++ b/src/main/java/com/demcha/compose/document/svg/SvgIconReader.java @@ -62,7 +62,8 @@ static SvgIcon read(String svgXml) { DocumentLineCap.BUTT, DocumentLineJoin.MITER, List.of()), box, gradients, skipped, layers); if (layers.isEmpty()) { - throw new IllegalArgumentException("SVG document contains no drawable geometry"); + throw new IllegalArgumentException( + "SVG document contains no drawable geometry" + skipped.reason()); } skipped.flush(); return new SvgIcon(layers, box[2], box[3]); @@ -131,58 +132,42 @@ private static void requirePositive(double width, double height, String source) private static void walk(Element element, double[] transform, Paint inherited, double[] box, Map gradients, SkipTally skipped, List out) { - Paint paint = stylize(element, inherited, gradients); - 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()) { - boolean strokeVisible = paint.stroke().visible() && paint.strokeWidth() > 0; - if (paint.fill().visible() || strokeVisible) { - SvgPath geometry = SvgPath.parseTransformed(d, matrix, box[0], box[1], box[2], box[3]); - - // Gradients resolve here, where the shape's geometry (the - // objectBoundingBox reference) and accumulated affine exist. - // The flat colour keeps the gradient's first stop so backends - // without shadings degrade per the DocumentPaint contract. - DocumentColor fillColor = paint.fill().color(); - DocumentPaint fillPaint = null; - if (paint.fill().gradient() != null) { - fillPaint = SvgGradients.paint(paint.fill().gradient(), gradients, - matrix, box, geometry); - fillColor = fillPaint.primaryColor(); - } - DocumentStroke stroke = null; - DocumentPaint strokePaint = null; - if (strokeVisible) { - if (paint.stroke().gradient() != null) { - strokePaint = SvgGradients.paint(paint.stroke().gradient(), gradients, - matrix, box, geometry); - stroke = DocumentStroke.of(strokePaint.primaryColor(), paint.strokeWidth()); - } else { - stroke = DocumentStroke.of(paint.stroke().color(), paint.strokeWidth()); - } - } - out.add(new SvgIcon.Layer(geometry, fillColor, fillPaint, stroke, strokePaint, - paint.lineCap(), paint.lineJoin(), paint.dashArray())); + // Process THIS element's own geometry with element context, so any + // unsupported colour / transform / gradient / unit names the offending + // element. Recursion stays outside the try — a child's error is already + // contextualized by its own walk, so it never double-wraps. + Paint paint; + double[] matrix; + try { + paint = stylize(element, inherited, gradients); + matrix = compose(transform, element.getAttribute("transform")); + String d = switch (name) { + case "path" -> element.getAttribute("d"); + case "rect" -> SvgShapeLowering.rect(num(element, "x"), num(element, "y"), + num(element, "width"), num(element, "height"), + num(element, "rx"), num(element, "ry")); + case "circle" -> SvgShapeLowering.ellipse(num(element, "cx"), num(element, "cy"), + num(element, "r"), num(element, "r")); + case "ellipse" -> SvgShapeLowering.ellipse(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" -> SvgShapeLowering.points(element.getAttribute("points"), false); + case "polygon" -> SvgShapeLowering.points(element.getAttribute("points"), true); + default -> null; + }; + + if (d != null && !d.isBlank()) { + emitLayer(element, name, d, paint, matrix, box, gradients, out); + } else if (DROPS_CONTENT.contains(name)) { + // A shape kind we deliberately don't render — count it so the icon + // surfaces one warning per kind instead of silently losing pixels. + skipped.note(name); } - } else if (DROPS_CONTENT.contains(name)) { - // A shape kind we deliberately don't render — count it so the icon - // surfaces one warning per kind instead of silently losing pixels. - skipped.note(name); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException( + "in " + describe(element) + ": " + e.getMessage(), e); } // Containers (svg, g, unknown wrappers) recurse; defs and metadata @@ -198,6 +183,59 @@ private static void walk(Element element, double[] transform, Paint inherited, } } + /** Builds and appends the layer for a drawable element (curve geometry + paint). */ + private static void emitLayer(Element element, String name, String d, Paint paint, + double[] matrix, double[] box, Map gradients, + List out) { + boolean strokeVisible = paint.stroke().visible() && paint.strokeWidth() > 0; + if (!paint.fill().visible() && !strokeVisible) { + return; + } + SvgPath geometry = SvgPath.parseTransformed(d, matrix, box[0], box[1], box[2], box[3]); + + // Gradients resolve here, where the shape's geometry (the + // objectBoundingBox reference) and accumulated affine exist. The flat + // colour keeps the gradient's first stop so backends without shadings + // degrade per the DocumentPaint contract. + DocumentColor fillColor = paint.fill().color(); + DocumentPaint fillPaint = null; + if (paint.fill().gradient() != null) { + fillPaint = SvgGradients.paint(paint.fill().gradient(), gradients, matrix, box, geometry); + fillColor = fillPaint.primaryColor(); + } + DocumentStroke stroke = null; + DocumentPaint strokePaint = null; + if (strokeVisible) { + if (paint.stroke().gradient() != null) { + strokePaint = SvgGradients.paint(paint.stroke().gradient(), gradients, matrix, box, geometry); + stroke = DocumentStroke.of(strokePaint.primaryColor(), paint.strokeWidth()); + } else { + stroke = DocumentStroke.of(paint.stroke().color(), paint.strokeWidth()); + } + } + out.add(new SvgIcon.Layer(geometry, fillColor, fillPaint, stroke, strokePaint, + paint.lineCap(), paint.lineJoin(), paint.dashArray())); + } + + /** + * A compact, log-safe descriptor of an element for error context: + * {@code }. Attributes are listed + * in document order; long values (notably {@code d}) are truncated. + */ + private static String describe(Element element) { + StringBuilder sb = new StringBuilder("<").append(element.getNodeName()); + org.w3c.dom.NamedNodeMap attrs = element.getAttributes(); + for (int i = 0; i < attrs.getLength() && sb.length() < 160; i++) { + Node attr = attrs.item(i); + String value = attr.getNodeValue(); + if (value != null && value.length() > 40) { + value = value.substring(0, 39) + "…"; + } + sb.append(' ').append(attr.getNodeName()).append("=\"").append(value).append('"'); + } + return sb.append('>').toString(); + } + private static Paint stylize(Element element, Paint inherited, Map gradients) { PaintValue fill = inherited.fill(); PaintValue stroke = inherited.stroke(); @@ -285,58 +323,11 @@ static DocumentColor color(String value, DocumentColor current) { return SvgStyles.color(value, current); } - 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"; - } - // ------------------------------------------------------------------ - // Shape lowering (synthesized path data through the tested parser) + // Shape lowering (synthesized path data through the tested parser) lives + // in SvgShapeLowering; the reader only extracts the numbers. // ------------------------------------------------------------------ - 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); @@ -460,5 +451,19 @@ void flush() { kinds); } } + + /** + * An error-message suffix naming what was skipped, so a blank icon + * explains itself ("…no drawable geometry — skipped: text, use; this + * reader renders vector shapes only"). Empty when nothing was skipped. + */ + String reason() { + if (kinds.isEmpty()) { + return ""; + } + return " — skipped " + String.join(", ", kinds) + + "; this reader renders vector shapes only (no text, images, , " + + "masks, clips or filters)"; + } } } diff --git a/src/main/java/com/demcha/compose/document/svg/SvgShapeLowering.java b/src/main/java/com/demcha/compose/document/svg/SvgShapeLowering.java new file mode 100644 index 00000000..2f95b5fb --- /dev/null +++ b/src/main/java/com/demcha/compose/document/svg/SvgShapeLowering.java @@ -0,0 +1,82 @@ +package com.demcha.compose.document.svg; + +/** + * Lowers SVG basic shapes ({@code rect}, {@code circle}, {@code ellipse}, + * {@code polyline}, {@code polygon}) to synthesized path-data strings, fed + * back through the one tested path parser ({@link SvgPath}) so every shape + * shares the same curve machinery. Pure string synthesis — no DOM, no state. + */ +final class SvgShapeLowering { + + private SvgShapeLowering() { + } + + /** + * Lowers a {@code } (optionally rounded) to path data. + * + * @param x left + * @param y top + * @param w width + * @param h height + * @param rx corner x-radius ({@code <= 0} for square / mirror of ry) + * @param ry corner y-radius ({@code <= 0} for square / mirror of rx) + * @return path data, or a plain rectangle when both radii are non-positive + */ + static String rect(double x, double y, double w, double h, double rx, double 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"; + } + + /** + * Lowers a {@code } / {@code } to two-arc path data. + * + * @param cx centre x + * @param cy centre y + * @param rx x-radius + * @param ry y-radius + * @return path data, or {@code null} for a non-positive radius (nothing drawn) + */ + static String ellipse(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"; + } + + /** + * Lowers {@code } / {@code } points to path data. + * + * @param points the raw {@code points} attribute + * @param close {@code true} to close the ring (polygon) + * @return path data, or {@code null} for empty points + */ + static String points(String points, boolean close) { + String trimmed = points == null ? "" : points.trim(); + if (trimmed.isEmpty()) { + return null; + } + return "M" + trimmed + (close ? " Z" : ""); + } +} diff --git a/src/test/java/com/demcha/compose/document/svg/SvgIconTest.java b/src/test/java/com/demcha/compose/document/svg/SvgIconTest.java index 87620be6..4ac3de64 100644 --- a/src/test/java/com/demcha/compose/document/svg/SvgIconTest.java +++ b/src/test/java/com/demcha/compose/document/svg/SvgIconTest.java @@ -472,4 +472,65 @@ void unsupportedContentElementsAreSkippedButGeometrySurvives() { assertThat(icon.layers()).hasSize(1); assertThat(icon.layers().get(0).fill().color()).isEqualTo(new java.awt.Color(18, 52, 86)); } + + // ------------------------------------------------------------------ + // Error clarity — say which element and why + // ------------------------------------------------------------------ + + @Test + void unsupportedColourNamesTheOffendingElement() { + assertThatThrownBy(() -> SvgIcon.parse(""" + + + + """)) + .isInstanceOf(IllegalArgumentException.class) + // pinpoints the element + the bad attribute, then the reason + .hasMessageContaining(" SvgIcon.parse(""" + + + + + + """)) + .isInstanceOf(IllegalArgumentException.class) + // the failing element is the path, not the wrapping + .hasMessageContaining(" SvgIcon.parse( + "")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(" SvgIcon.parse(""" + + hello + + """)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("no drawable geometry") + .hasMessageContaining("skipped text") + .hasMessageContaining("vector shapes only"); + } } diff --git a/src/test/java/com/demcha/compose/document/svg/SvgShapeLoweringTest.java b/src/test/java/com/demcha/compose/document/svg/SvgShapeLoweringTest.java new file mode 100644 index 00000000..96bddbb9 --- /dev/null +++ b/src/test/java/com/demcha/compose/document/svg/SvgShapeLoweringTest.java @@ -0,0 +1,49 @@ +package com.demcha.compose.document.svg; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit coverage for the basic-shape → path-data lowering: each synthesized + * {@code d} string must parse back through {@link SvgPath} and produce the + * expected geometry. + */ +class SvgShapeLoweringTest { + + @Test + void plainRectLowersToAClosedRectangleSubpath() { + String d = SvgShapeLowering.rect(1, 2, 8, 6, 0, 0); + assertThat(d).isEqualTo("M1.0 2.0 h8.0 v6.0 h-8.0 Z"); + // M + 3 drawing ops + close → 5 segments through the real parser. + assertThat(SvgPath.parse(d).segments()).hasSize(5); + } + + @Test + void roundedRectMirrorsASingleRadiusAndArcsTheCorners() { + // Only rx given → ry mirrors it; the result carries four arc corners, + // each an arc that lowers to cubic Béziers. + String d = SvgShapeLowering.rect(0, 0, 10, 10, 3, 0); + long cubics = SvgPath.parse(d).segments().stream() + .filter(com.demcha.compose.document.style.DocumentPathSegment.CubicTo.class::isInstance) + .count(); + assertThat(cubics).isGreaterThanOrEqualTo(4); + } + + @Test + void ellipseLowersToTwoArcsAndNullsAZeroRadius() { + assertThat(SvgShapeLowering.ellipse(5, 5, 4, 3)).startsWith("M"); + assertThat(SvgPath.parse(SvgShapeLowering.ellipse(5, 5, 4, 3)).segments()) + .anyMatch(com.demcha.compose.document.style.DocumentPathSegment.CubicTo.class::isInstance); + assertThat(SvgShapeLowering.ellipse(5, 5, 0, 3)).isNull(); + assertThat(SvgShapeLowering.ellipse(5, 5, 4, 0)).isNull(); + } + + @Test + void pointsClosesForPolygonAndStaysOpenForPolyline() { + assertThat(SvgShapeLowering.points("0,0 4,0 2,3", true)).isEqualTo("M0,0 4,0 2,3 Z"); + assertThat(SvgShapeLowering.points("0,0 4,0 2,3", false)).isEqualTo("M0,0 4,0 2,3"); + assertThat(SvgShapeLowering.points(" ", true)).isNull(); + assertThat(SvgShapeLowering.points(null, false)).isNull(); + } +} From f2fb9b0da2136904a5c2c834eafae77504e29031 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sat, 13 Jun 2026 14:27:41 +0100 Subject: [PATCH 2/2] feat(svg): malformed-number errors name the field and value num()/viewBox()/transform parsing routed through a parseNumber helper that throws ' must be a number, got ' with the cause chained, instead of leaking the raw JDK 'For input string'. Drops a dead emitLayer param and fixes the describe() truncation off-by-one. Adds negative tests. --- .../compose/document/svg/SvgIconReader.java | 37 +++++++++++++------ .../compose/document/svg/SvgIconTest.java | 29 +++++++++++++++ 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/demcha/compose/document/svg/SvgIconReader.java b/src/main/java/com/demcha/compose/document/svg/SvgIconReader.java index 958e4f1c..c1745111 100644 --- a/src/main/java/com/demcha/compose/document/svg/SvgIconReader.java +++ b/src/main/java/com/demcha/compose/document/svg/SvgIconReader.java @@ -101,10 +101,10 @@ private static double[] viewBox(Element svg) { 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]); + double minX = parseNumber(parts[0], "viewBox min-x"); + double minY = parseNumber(parts[1], "viewBox min-y"); + double width = parseNumber(parts[2], "viewBox width"); + double height = parseNumber(parts[3], "viewBox height"); requirePositive(width, height, viewBox); return new double[]{minX, minY, width, height}; } @@ -113,8 +113,8 @@ private static double[] viewBox(Element svg) { 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); + double width = parseNumber(w, "width"); + double height = parseNumber(h, "height"); requirePositive(width, height, w + " x " + h); return new double[]{0, 0, width, height}; } @@ -159,7 +159,7 @@ private static void walk(Element element, double[] transform, Paint inherited, }; if (d != null && !d.isBlank()) { - emitLayer(element, name, d, paint, matrix, box, gradients, out); + emitLayer(element, d, paint, matrix, box, gradients, out); } else if (DROPS_CONTENT.contains(name)) { // A shape kind we deliberately don't render — count it so the icon // surfaces one warning per kind instead of silently losing pixels. @@ -184,7 +184,7 @@ private static void walk(Element element, double[] transform, Paint inherited, } /** Builds and appends the layer for a drawable element (curve geometry + paint). */ - private static void emitLayer(Element element, String name, String d, Paint paint, + private static void emitLayer(Element element, String d, Paint paint, double[] matrix, double[] box, Map gradients, List out) { boolean strokeVisible = paint.stroke().visible() && paint.strokeWidth() > 0; @@ -229,7 +229,7 @@ private static String describe(Element element) { Node attr = attrs.item(i); String value = attr.getNodeValue(); if (value != null && value.length() > 40) { - value = value.substring(0, 39) + "…"; + value = value.substring(0, 40) + "…"; } sb.append(' ').append(attr.getNodeName()).append("=\"").append(value).append('"'); } @@ -330,7 +330,22 @@ static DocumentColor color(String value, DocumentColor current) { private static double num(Element element, String attribute) { String value = element.getAttribute(attribute).trim(); - return value.isEmpty() ? 0.0 : Double.parseDouble(value); + return value.isEmpty() ? 0.0 : parseNumber(value, attribute); + } + + /** + * Parses a numeric SVG value, naming the field and the offending input on + * failure instead of leaking the raw {@link NumberFormatException} message + * ("For input string: …"). The cause is chained so the JDK detail survives + * for anyone who needs it. + */ + private static double parseNumber(String value, String what) { + try { + return Double.parseDouble(value); + } catch (NumberFormatException e) { + throw new IllegalArgumentException( + what + " must be a number, got '" + value + "'", e); + } } static double[] identity() { @@ -375,7 +390,7 @@ static double[] compose(double[] parent, String transformAttribute) { 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]); + v[i] = parseNumber(args[i], "transform '" + source + "' argument"); } return switch (op) { case "translate" -> new double[]{1, 0, 0, 1, v[0], v.length > 1 ? v[1] : 0}; diff --git a/src/test/java/com/demcha/compose/document/svg/SvgIconTest.java b/src/test/java/com/demcha/compose/document/svg/SvgIconTest.java index 4ac3de64..f7c41772 100644 --- a/src/test/java/com/demcha/compose/document/svg/SvgIconTest.java +++ b/src/test/java/com/demcha/compose/document/svg/SvgIconTest.java @@ -533,4 +533,33 @@ void blankIconExplainsWhatItSkipped() { .hasMessageContaining("skipped text") .hasMessageContaining("vector shapes only"); } + + @Test + void malformedNumericAttributeNamesElementAttributeAndReason() { + // A non-numeric geometry attribute must name the element AND say which + // field expected a number — not leak a bare JDK "For input string". + assertThatThrownBy(() -> SvgIcon.parse(""" + + + + """)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(" SvgIcon.parse(""" + + + + """)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("viewBox") + .hasMessageContaining("must be a number"); + } }