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("""