From c240cd0cdf19cff7f1e30dbc4a402d029c0042b4 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Sun, 14 Jun 2026 17:58:56 +0100 Subject: [PATCH] fix(svg): drop SVG icon layers with no drawing segment A visible-painted SVG element that lowers to a moveto-only or moveto+close path (d="M12 12", a zero-length arc, the stray subpaths exporters emit) draws no ink, yet a lone moveto threw at SvgIcon#node(...) building an empty PathNode and a moveto+close rendered blank. SvgIconReader.emitLayer now drops a layer whose geometry has no LineTo/CubicTo (new package-private SvgPath.hasDrawingSegment()), so one degenerate element no longer fails the whole icon. An icon of only such elements still fails loudly via the existing 'no drawable geometry' guard. Adds two SvgIconTest cases. Full suite green (1380). --- CHANGELOG.md | 8 +++++ .../compose/document/svg/SvgIconReader.java | 7 +++++ .../demcha/compose/document/svg/SvgPath.java | 19 ++++++++++++ .../compose/document/svg/SvgIconTest.java | 29 +++++++++++++++++++ 4 files changed, 63 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e6191891..19c44ff5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -228,6 +228,14 @@ Entries land here as they merge. ### Bug fixes +- **A stray non-drawing element no longer breaks a whole SVG icon.** A + visible-painted SVG element that lowers to a moveto-only or moveto+close + path — `d="M12 12"`, a zero-length arc, the stray subpaths real exporters + emit — drew no ink, yet a lone moveto threw at `SvgIcon#node(...)` (an empty + `PathNode`) and a moveto+close rendered blank. `SvgIconReader` now drops a + layer with no drawing segment, so one degenerate element no longer fails the + icon; an icon of only such elements still fails loudly with "no drawable + geometry". - **Stacked bars anchor at zero even with an explicit positive axis minimum.** A stacked bar chart with `valueAxis().min(positive)` lifted the baseline while segment heights stayed measured from zero, so the stack overshot its 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 c1745111d..557424232 100644 --- a/src/main/java/com/demcha/compose/document/svg/SvgIconReader.java +++ b/src/main/java/com/demcha/compose/document/svg/SvgIconReader.java @@ -192,6 +192,13 @@ private static void emitLayer(Element element, String d, Paint paint, return; } SvgPath geometry = SvgPath.parseTransformed(d, matrix, box[0], box[1], box[2], box[3]); + if (!geometry.hasDrawingSegment()) { + // A moveto-only or moveto+close element (e.g. d="M12 12", or a + // zero-length arc that lowers to a lone moveto) draws no ink. Drop + // it so one degenerate element a real-world exporter emitted does + // not fail the whole icon at SvgIcon#node(...). + return; + } // Gradients resolve here, where the shape's geometry (the // objectBoundingBox reference) and accumulated affine exist. The flat 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 a48a5fcb0..813362df2 100644 --- a/src/main/java/com/demcha/compose/document/svg/SvgPath.java +++ b/src/main/java/com/demcha/compose/document/svg/SvgPath.java @@ -154,6 +154,25 @@ public double aspectRatio() { return sourceWidth / sourceHeight; } + /** + * Whether this path actually draws ink: it carries at least one + * {@code lineTo} / {@code cubicTo} after the leading {@code moveTo}. A + * moveto-only path, or a moveto followed only by {@code close} (which a + * degenerate SVG element such as {@code d="M12 12"} or a zero-length arc + * lowers to), draws nothing. + * + * @return {@code true} if a drawing segment is present + */ + boolean hasDrawingSegment() { + for (DocumentPathSegment segment : segments) { + if (segment instanceof DocumentPathSegment.LineTo + || segment instanceof DocumentPathSegment.CubicTo) { + return true; + } + } + return false; + } + // ------------------------------------------------------------------ // Normalization (y-flip into the unit box) // ------------------------------------------------------------------ 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 32c0691ea..ce12c8ca4 100644 --- a/src/test/java/com/demcha/compose/document/svg/SvgIconTest.java +++ b/src/test/java/com/demcha/compose/document/svg/SvgIconTest.java @@ -67,6 +67,35 @@ void invisibleElementsProduceNoLayers() { assertThat(icon.layers()).hasSize(1); } + @Test + void degenerateGeometryIsDroppedSoOneStrayElementDoesNotFailTheIcon() { + // Real exporters emit stray moveto-only / moveto+close subpaths that + // draw nothing. They must not break the whole icon: a lone moveto used + // to throw at node() (an empty PathNode), a moveto+close rendered blank. + SvgIcon icon = SvgIcon.parse(""" + + + + + + """); + + // Only the drawable box survives; both degenerate paths are dropped. + assertThat(icon.layers()).hasSize(1); + assertThat(icon.node(24)).isNotNull(); // threw before the drop guard + } + + @Test + void anIconOfOnlyDegenerateGeometryFailsLoudly() { + assertThatThrownBy(() -> SvgIcon.parse(""" + + + + """)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("no drawable geometry"); + } + @Test void basicShapesLowerToPathGeometry() { SvgIcon icon = SvgIcon.parse("""