From 5552a03bb32284c8b530fef680daf2a29713aac7 Mon Sep 17 00:00:00 2001 From: Sebastiano Poggi Date: Wed, 22 Jul 2026 21:01:42 +0200 Subject: [PATCH] [JEWEL-1370] Align stroking on the new icons API with the legacy path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to JEWEL-1369. Comparing the new cross-frontend icons API (com.intellij.platform.icons) against the legacy IconLoader/SVGLoader stroking, which is the reference implementation, surfaced three further behaviours where the two still disagreed. All three are long-standing gaps rather than regressions, and the legacy path is left untouched. Hand-authored _stroke.svg variants took the wrong patch. Such a variant is a separate drawing, already reduced to an outline by hand and authored in white, so the legacy path only recolors it; the new API applied the full palette substitution instead, including turning background tints transparent. authoredStrokeSvgPatcher now sits beside strokeSvgPatcher, and each frontend picks one from the file it resolved. Stroking such a variant in opaque white patches nothing, matching the legacy short-circuit, so a variant carrying its own opacity is left exactly as authored. The Compose frontend did not look for that variant at all, so a stroked expui/run/run.svg rendered as the base file with its off-palette green outline intact, ignoring the requested color entirely. It now resolves the variant the way the Swing frontend already did, and falls back to light artwork rather than a dark variant when the icon ships none. Opacity and alpha were ignored. The fill and stroke attributes carry no alpha channel — alpha belongs on fill-opacity and its siblings — so a translucent stroke color was written out as #RRGGBBAA, and an opacity the document was authored with went on shading a color that had just been replaced. writeSvgAttribute splits a color across the pair, which is what the legacy patcher does through its alphaProvider. Both frontends write through it, for the same reason both already resolve conditions through SvgPatchOperation.matches. Short hex forms never matched. Attribute values were compared as text, so fill="#fff" missed the palette's #ffffff. Color comparison now canonicalizes hex literals, expanding the three- and four-digit shorthands. It compares the color alone: an alpha the literal carries is not part of its identity, since a substitution replaces the opacity of what it matched along with the color. A literal of a length that names no color, such as five digits, keeps comparing as text rather than being expanded into a color it does not name. closes https://github.com/JetBrains/intellij-community/pull/3588 (cherry picked from commit c67d6706ac6824faf1ccc439c11d2002ad706eec) IJ-MR-220560 GitOrigin-RevId: 5f3f3d75ecd8a968958053c1f0ceb180b13ddeb7 --- .../impl/intellij/rendering/Convertors.kt | 72 ++++++++++++---- .../images/SwingImageResourceHolder.kt | 18 ++-- .../icons/impl/intellij/StrokePatcherTest.kt | 54 +++++++++++- .../icons/impl/intellij/StrokeRenderTest.kt | 34 ++++++++ .../impl/intellij/SvgAttributeWriterTest.kt | 75 +++++++++++++++++ .../intellij/SvgPatchOperationMatchesTest.kt | 26 ++++++ .../icons/impl/patchers/StrokePatcher.kt | 38 +++++++++ .../icons/impl/patchers/SvgAttributeWriter.kt | 70 ++++++++++++++++ .../icons/impl/patchers/SvgPatcher.kt | 45 ++++++++-- .../ui/icon/ComposeImageResourceProvider.kt | 32 +++++--- .../ui/icon/PathImageResourceLocation.kt | 82 ++++++++++++++----- .../ui/icon/PathImageResourceLocationTest.kt | 67 +++++++++++++++ .../test/resources/icons/strokeVariant.svg | 4 + .../resources/icons/strokeVariant_stroke.svg | 4 + 14 files changed, 551 insertions(+), 70 deletions(-) create mode 100644 platform/icons-impl/intellij/tests/test/com/intellij/platform/icons/impl/intellij/SvgAttributeWriterTest.kt create mode 100644 platform/icons-impl/src/com/intellij/platform/icons/impl/patchers/SvgAttributeWriter.kt create mode 100644 platform/jewel/ui/src/test/kotlin/org/jetbrains/jewel/ui/icon/PathImageResourceLocationTest.kt create mode 100644 platform/jewel/ui/src/test/resources/icons/strokeVariant.svg create mode 100644 platform/jewel/ui/src/test/resources/icons/strokeVariant_stroke.svg diff --git a/platform/icons-impl/intellij/src/com/intellij/platform/icons/impl/intellij/rendering/Convertors.kt b/platform/icons-impl/intellij/src/com/intellij/platform/icons/impl/intellij/rendering/Convertors.kt index e26b57384188..de5eef80ced5 100644 --- a/platform/icons-impl/intellij/src/com/intellij/platform/icons/impl/intellij/rendering/Convertors.kt +++ b/platform/icons-impl/intellij/src/com/intellij/platform/icons/impl/intellij/rendering/Convertors.kt @@ -6,11 +6,16 @@ import com.intellij.util.SVGLoader import com.intellij.platform.icons.design.BlendMode import com.intellij.platform.icons.filters.ColorFilter import com.intellij.platform.icons.impl.filters.TintColorFilter +import com.intellij.platform.icons.impl.patchers.AUTHORED_STROKE_VARIANT_SUFFIX import com.intellij.platform.icons.impl.patchers.DefaultSvgPatcher import com.intellij.platform.icons.impl.patchers.SvgPatchOperation +import com.intellij.platform.icons.impl.patchers.authoredStrokeSvgPatcher +import com.intellij.platform.icons.impl.patchers.strokeSvgPatcher +import com.intellij.platform.icons.impl.patchers.writeSvgAttribute import com.intellij.platform.icons.swing.toAwtColor import java.awt.Color import java.awt.image.RGBImageFilter +import com.intellij.platform.icons.design.Color as DesignColor internal fun ColorFilter.toAwtFilter(): RGBImageFilter { return when (this) { @@ -21,45 +26,76 @@ internal fun ColorFilter.toAwtFilter(): RGBImageFilter { } } -internal fun DefaultSvgPatcher.toIJPatcher(rootPatcher: SVGLoader.SvgElementColorPatcherProvider?): SVGLoader.SvgElementColorPatcherProvider { - return ProxySvgPatcher(this, rootPatcher) +/** + * The color patcher this frontend hands to the IntelliJ SVG loader for an icon stroked in [stroke] and patched by + * [patcher], on top of whatever [rootPatcher] already patches. + * + * Returns `null` when there is nothing of our own to patch, which is what tells the loader to take its plain path. + */ +internal fun toIJPatcher( + stroke: DesignColor?, + patcher: DefaultSvgPatcher?, + rootPatcher: SVGLoader.SvgElementColorPatcherProvider?, +): SVGLoader.SvgElementColorPatcherProvider? { + if (stroke == null && patcher == null) return null + return ProxySvgPatcher(stroke = stroke, patcher = patcher, rootPatcher = rootPatcher) } private class ProxySvgPatcher( - private val patcher: DefaultSvgPatcher, + private val stroke: DesignColor?, + private val patcher: DefaultSvgPatcher?, private val rootPatcher: SVGLoader.SvgElementColorPatcherProvider? = null -): SVGLoader.SvgElementColorPatcherProvider, SvgAttributePatcher { - override fun attributeForPath(path: String): SvgAttributePatcher = ProxySvgAttributePatcher(patcher, rootPatcher?.attributeForPath(path)) +): SVGLoader.SvgElementColorPatcherProvider { + // Which stroke patch applies depends on the file the loader ends up resolving, so it can only be picked per path: a + // hand-authored stroke variant is recolored as it is, while a base icon is reduced to an outline. + override fun attributeForPath(path: String): SvgAttributePatcher { + val strokePatcher = stroke?.let { + if (path.isAuthoredStrokeVariant()) authoredStrokeSvgPatcher(it) else strokeSvgPatcher(it) + } + // The icon's own patcher runs first and the stroke substitution after it, so an icon that explicitly recolors a + // palette color keeps that color: the stroke operation no longer matches what the explicit one already replaced. + val combined = patcher?.combineWith(strokePatcher) ?: strokePatcher + return ProxySvgAttributePatcher(combined as? DefaultSvgPatcher, rootPatcher?.attributeForPath(path)) + } + override fun digest(): LongArray { + val own = longArrayOf(patcher.hashCode().toLong(), stroke.hashCode().toLong()) if (rootPatcher != null) { - return rootPatcher.digest() + longArrayOf(patcher.hashCode().toLong()) + return rootPatcher.digest() + own } else { - return longArrayOf(patcher.hashCode().toLong()) + return own } } } +private fun String.isAuthoredStrokeVariant(): Boolean = + substringBeforeLast('.', missingDelimiterValue = "").endsWith(AUTHORED_STROKE_VARIANT_SUFFIX) + private class ProxySvgAttributePatcher( - private val patcher: DefaultSvgPatcher, + private val patcher: DefaultSvgPatcher?, private val rootPatcher: SvgAttributePatcher? = null ): SvgAttributePatcher { override fun patchColors(attributes: MutableMap) { rootPatcher?.patchColors(attributes) // TODO Support filtered operations - not possible with current IJ svg loader - for (operation in patcher.operations) { + val write = { name: String, value: String -> + writeSvgAttribute(name, value, { n, v -> attributes[n] = v }, { attributes.remove(it) }) + } + for (operation in patcher?.operations ?: return) { when (operation.operation) { SvgPatchOperation.Operation.Add -> { if (!attributes.containsKey(operation.attributeName)) { - attributes[operation.attributeName] = operation.value!! + write(operation.attributeName, operation.value!!) } } SvgPatchOperation.Operation.Replace -> { - if (operation.conditional) { - if (operation.matches(attributes[operation.attributeName]) != operation.negatedCondition) { - attributes.replace(operation.attributeName, operation.value!!) - } - } else { - attributes.replace(operation.attributeName, operation.value!!) + // Replace never creates an attribute, conditionally or not: an element that does not carry the attribute + // inherits it, and adding one here would override that inheritance. Add exists for that. + if (attributes.containsKey(operation.attributeName) && + (!operation.conditional || + operation.matches(attributes[operation.attributeName]) != operation.negatedCondition) + ) { + write(operation.attributeName, operation.value!!) } } SvgPatchOperation.Operation.Remove -> { @@ -71,7 +107,7 @@ private class ProxySvgAttributePatcher( attributes.remove(operation.attributeName) } } - SvgPatchOperation.Operation.Set -> attributes[operation.attributeName] = operation.value!! + SvgPatchOperation.Operation.Set -> write(operation.attributeName, operation.value!!) } } } @@ -93,7 +129,7 @@ private class AwtColorFilter(color: Color, val keepGray: Boolean, val keepBright } companion object { - fun fromColorAndBlendMode(color: com.intellij.platform.icons.design.Color, blendMode: BlendMode): AwtColorFilter { + fun fromColorAndBlendMode(color: DesignColor, blendMode: BlendMode): AwtColorFilter { var keepGray = true var keepBrightness = true when (blendMode) { diff --git a/platform/icons-impl/intellij/src/com/intellij/platform/icons/impl/intellij/rendering/images/SwingImageResourceHolder.kt b/platform/icons-impl/intellij/src/com/intellij/platform/icons/impl/intellij/rendering/images/SwingImageResourceHolder.kt index 951d75fe80aa..69b6b71c7280 100644 --- a/platform/icons-impl/intellij/src/com/intellij/platform/icons/impl/intellij/rendering/images/SwingImageResourceHolder.kt +++ b/platform/icons-impl/intellij/src/com/intellij/platform/icons/impl/intellij/rendering/images/SwingImageResourceHolder.kt @@ -7,7 +7,6 @@ import com.intellij.ui.scale.ScaleContext import com.intellij.platform.icons.impl.intellij.rendering.toAwtFilter import com.intellij.platform.icons.impl.intellij.rendering.toIJPatcher import com.intellij.platform.icons.impl.patchers.DefaultSvgPatcher -import com.intellij.platform.icons.impl.patchers.strokeSvgPatcher import com.intellij.platform.icons.impl.rendering.DefaultImageModifiers import com.intellij.platform.icons.rendering.Dimensions import com.intellij.platform.icons.rendering.ImageModifiers @@ -26,15 +25,16 @@ internal fun ImageModifiers?.toLoadParameters(): LoadIconParameters { filters.add(colorFilter.toAwtFilter()) } val knownModifiers = this as? DefaultImageModifiers - val strokePatcher = knownModifiers?.stroke?.let { strokeSvgPatcher(it) } val ijModifiers = this as? IntelliJImageModifiers - // The icon's own patcher runs first and the stroke substitution after it, so an icon that explicitly recolors a - // palette color keeps that color: the stroke operation no longer matches what the explicit one already replaced. - // The elvis is what keeps a stroke-only icon patched at all — `svgPatcher` is null whenever an icon carries no - // explicit patcher, and combining outwards from null would discard the stroke patcher entirely. - val combinedPatcher = this?.svgPatcher?.combineWith(strokePatcher) ?: strokePatcher - val colorPatcher = (combinedPatcher as? DefaultSvgPatcher)?.toIJPatcher(ijModifiers?.legacyPatcherProvider) - val isStroke = knownModifiers?.stroke != null + val stroke = knownModifiers?.stroke + // The stroke patch is combined per resolved path rather than here, because which patch an icon takes depends on + // whether the loader ends up resolving a hand-authored stroke variant for it. + val colorPatcher = toIJPatcher( + stroke = stroke, + patcher = this?.svgPatcher as? DefaultSvgPatcher, + rootPatcher = ijModifiers?.legacyPatcherProvider, + ) + val isStroke = stroke != null return LoadIconParameters( filters = filters, // A stroked icon loads its light artwork even in a dark theme. The palette describes the light variants, so diff --git a/platform/icons-impl/intellij/tests/test/com/intellij/platform/icons/impl/intellij/StrokePatcherTest.kt b/platform/icons-impl/intellij/tests/test/com/intellij/platform/icons/impl/intellij/StrokePatcherTest.kt index 0612262026b7..8f6522c486be 100644 --- a/platform/icons-impl/intellij/tests/test/com/intellij/platform/icons/impl/intellij/StrokePatcherTest.kt +++ b/platform/icons-impl/intellij/tests/test/com/intellij/platform/icons/impl/intellij/StrokePatcherTest.kt @@ -1,7 +1,9 @@ // Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package com.intellij.platform.icons.impl.intellij +import com.intellij.platform.icons.design.Color import com.intellij.platform.icons.impl.design.DefaultSRGB +import com.intellij.platform.icons.impl.patchers.authoredStrokeSvgPatcher import com.intellij.platform.icons.impl.patchers.DefaultSvgPatcher import com.intellij.platform.icons.impl.patchers.SvgPatchOperation import com.intellij.platform.icons.impl.patchers.strokeSvgPatcher @@ -75,6 +77,43 @@ class StrokePatcherTest { assert(unconditional.isEmpty()) { "unconditional operations would erase off-palette artwork: $unconditional" } } + @Test + fun `stroking a hand-authored variant recolors the white it is drawn in, on both attributes`() { + for (value in AUTHORED_STROKE_COLORS) { + for (attribute in ATTRIBUTES) { + val match = conditionalReplacement(attribute, value, operations(authored = true)) + assertNotNull(match) { "no $attribute replacement for $value in a hand-authored stroke variant" } + assert(match.value == red.toHex()) { "$attribute for $value became ${match.value}, wanted red" } + } + } + } + + @Test + fun `stroking a hand-authored variant reduces no palette color`() { + // The variant is already an outline; dropping a background tint out of it could only take away artwork, since a + // tint that reads as a background in a filled icon can be the outline itself in a drawing made of outlines. + val expected = AUTHORED_STROKE_COLORS.size * ATTRIBUTES.size + val actual = operations(authored = true).size + assert(actual == expected) { + "expected $expected operations to recolor a hand-authored variant, got $actual: ${operations(authored = true)}" + } + } + + @Test + fun `stroking a hand-authored variant in opaque white patches nothing`() { + // The variant is already drawn in white, so it is left exactly as authored — including any opacity it carries, + // which recoloring white to white would still normalize away. + val operations = operations(authored = true, stroke = DefaultSRGB.fromHex("#FFFFFFFF")) + assert(operations.isEmpty()) { "stroking white must leave a hand-authored variant untouched, got $operations" } + } + + @Test + fun `stroking a hand-authored variant in translucent white still recolors`() { + // Translucent white is a different color from the white the variant is drawn in, so it is a real substitution. + val operations = operations(authored = true, stroke = DefaultSRGB.fromHex("#FFFFFF80")) + assert(operations.isNotEmpty()) { "translucent white is not the color the variant is authored in" } + } + private fun assertRecolored(value: String, what: String) { for (attribute in ATTRIBUTES) { val match = conditionalReplacement(attribute, value) @@ -83,16 +122,20 @@ class StrokePatcherTest { } } - private fun conditionalReplacement(attribute: String, expectedValue: String): SvgPatchOperation? = - operations().singleOrNull { + private fun conditionalReplacement( + attribute: String, + expectedValue: String, + operations: List = operations(), + ): SvgPatchOperation? = + operations.singleOrNull { it.attributeName == attribute && it.operation == SvgPatchOperation.Operation.Replace && it.conditional && it.expectedValue == expectedValue } - private fun operations(): List { - val patcher = strokeSvgPatcher(red) as? DefaultSvgPatcher + private fun operations(authored: Boolean = false, stroke: Color = red): List { + val patcher = (if (authored) authoredStrokeSvgPatcher(stroke) else strokeSvgPatcher(stroke)) as? DefaultSvgPatcher assertNotNull(patcher) return patcher.operations } @@ -113,5 +156,8 @@ class StrokePatcherTest { listOf( "#ebecf0", "#e7effd", "#dff2e0", "#f2fcf3", "#ffe8e8", "#fff5f5", "#fff8e3", "#fff4eb", "#eee0ff", ) + + // A hand-authored stroke variant is drawn in white alone, in either spelling an SVG may use for it. + private val AUTHORED_STROKE_COLORS = listOf("white", "#ffffff") } } diff --git a/platform/icons-impl/intellij/tests/test/com/intellij/platform/icons/impl/intellij/StrokeRenderTest.kt b/platform/icons-impl/intellij/tests/test/com/intellij/platform/icons/impl/intellij/StrokeRenderTest.kt index 91e382ff6a07..0b45bb818b9f 100644 --- a/platform/icons-impl/intellij/tests/test/com/intellij/platform/icons/impl/intellij/StrokeRenderTest.kt +++ b/platform/icons-impl/intellij/tests/test/com/intellij/platform/icons/impl/intellij/StrokeRenderTest.kt @@ -10,6 +10,7 @@ import com.intellij.platform.icons.patchers.svgPatcher import com.intellij.platform.icons.swing.toSwingIcon import com.intellij.testFramework.junit5.TestApplication import java.awt.image.BufferedImage +import kotlin.math.roundToInt import javax.swing.UIManager import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue @@ -98,6 +99,39 @@ class StrokeRenderTest { assertEquals(0, counts.red, "$path: the stroke color overrode the icon's own patcher, got $counts") } + @Test + fun `icon with a hand-authored stroke variant strokes red`() { + // run.svg is a palette background fill inside an off-palette green outline, so reducing it to an outline cannot + // produce the stroked glyph — the icon ships run_stroke.svg, a separate drawing, for exactly that. + val stroked = count(render("expui/run/run.svg", IconModifier.stroke(red))) + println("[stroke-render] expui/run/run.svg stroked=[$stroked]") + + assertTrue(stroked.red > 0) { "expected stroking to produce red pixels, got $stroked" } + assertEquals(0, stroked.green, "the authored green outline must not survive stroking, got $stroked") + } + + @Test + fun `stroking replaces the opacity a shape was authored with`() { + // cursorText.svg draws one of its shapes at fill-opacity="0.7". That opacity described the color stroking has just + // replaced, so it cannot go on shading the color that replaced it. + val stroked = render("expui/windows/mouse/cursorText.svg", IconModifier.stroke(red)) + val authored = (0.7f * 255).roundToInt() + val atAuthoredOpacity = (authored - 1..authored + 1).sumOf { alpha -> countAtAlpha(stroked, alpha) } + println("[stroke-render] cursorText.svg stroked=[${count(stroked)}] atAuthoredOpacity=$atAuthoredOpacity") + + assertEquals(0, atAuthoredOpacity, "pixels are still rendered at the authored 0.7 opacity") + } + + private fun countAtAlpha(image: BufferedImage, alpha: Int): Int { + var count = 0 + for (y in 0 until image.height) { + for (x in 0 until image.width) { + if (image.getRGB(x, y) ushr 24 and 0xFF == alpha) count++ + } + } + return count + } + private fun assertStrokedRed(path: String) { val plain = count(render(path, IconModifier)) val stroked = count(render(path, IconModifier.stroke(red))) diff --git a/platform/icons-impl/intellij/tests/test/com/intellij/platform/icons/impl/intellij/SvgAttributeWriterTest.kt b/platform/icons-impl/intellij/tests/test/com/intellij/platform/icons/impl/intellij/SvgAttributeWriterTest.kt new file mode 100644 index 000000000000..7712088b5103 --- /dev/null +++ b/platform/icons-impl/intellij/tests/test/com/intellij/platform/icons/impl/intellij/SvgAttributeWriterTest.kt @@ -0,0 +1,75 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.platform.icons.impl.intellij + +import com.intellij.platform.icons.impl.patchers.writeSvgAttribute +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +/** + * Pins how a patch writes a color, which every frontend does through this one function so that the same operation + * produces the same document everywhere. + * + * A color attribute carries no alpha channel — alpha lives on the paired opacity attribute — so writing a color has to + * land across both, and the opacity the document was authored with described the color that was just replaced. + */ +class SvgAttributeWriterTest { + @Test + fun `a translucent color is split across the color attribute and its opacity`() { + // #ff000080 is not a value `fill` can hold: the attribute has no alpha channel. + val result = write("fill", "#ff000080") + + assertEquals("#ff0000", result["fill"]) + assertEquals(0x80 / 255f, result["fill-opacity"]?.toFloat(), "the alpha belongs on fill-opacity") + } + + @Test + fun `an opaque color clears the opacity the document was authored with`() { + val result = write("fill", "#ff0000", mapOf("fill-opacity" to "0.7")) + + assertEquals("#ff0000", result["fill"]) + assertNull(result["fill-opacity"], "0.7 described the color that was just replaced, not the new one") + } + + @Test + fun `every color attribute with a paired opacity carries its own`() { + for ((attribute, opacity) in PAIRS) { + val result = write(attribute, "#ff000080", mapOf(opacity to "0.7")) + assertEquals("#ff0000", result[attribute]) + assertEquals(0x80 / 255f, result[opacity]?.toFloat()) { "$attribute did not write $opacity" } + } + } + + @Test + fun `a value that is not a color leaves the opacity alone`() { + val result = write("fill", "transparent", mapOf("fill-opacity" to "0.7")) + + assertEquals("transparent", result["fill"]) + assertEquals("0.7", result["fill-opacity"], "only a color write says anything about opacity") + } + + @Test + fun `an attribute that holds no color is written verbatim`() { + assertEquals("#ff000080", write("stroke-width", "#ff000080")["stroke-width"]) + } + + private fun write( + attributeName: String, + value: String, + attributes: Map = emptyMap(), + ): Map { + val result = attributes.toMutableMap() + writeSvgAttribute(attributeName, value, { name, written -> result[name] = written }, { result.remove(it) }) + return result + } + + private companion object { + private val PAIRS = + listOf( + "fill" to "fill-opacity", + "stroke" to "stroke-opacity", + "stop-color" to "stop-opacity", + "flood-color" to "flood-opacity", + ) + } +} diff --git a/platform/icons-impl/intellij/tests/test/com/intellij/platform/icons/impl/intellij/SvgPatchOperationMatchesTest.kt b/platform/icons-impl/intellij/tests/test/com/intellij/platform/icons/impl/intellij/SvgPatchOperationMatchesTest.kt index 0677745c1edf..d4e29732ec92 100644 --- a/platform/icons-impl/intellij/tests/test/com/intellij/platform/icons/impl/intellij/SvgPatchOperationMatchesTest.kt +++ b/platform/icons-impl/intellij/tests/test/com/intellij/platform/icons/impl/intellij/SvgPatchOperationMatchesTest.kt @@ -40,10 +40,36 @@ class SvgPatchOperationMatchesTest { } } + @Test + fun `hex shorthands match the form they stand for`() { + // Every digit stands for a doubled pair, so #fff names the same color as #ffffff. + assertTrue(condition("fill", "#ffffff").matches("#fff")) + assertTrue(condition("fill", "#fff").matches("#FFFFFF")) + assertTrue(condition("fill", "#66cc77").matches("#6c7")) + } + + @Test + fun `an alpha the literal carries is not part of which color it names`() { + // A substitution replaces the opacity of what it matched along with the color, so the authored alpha does not + // decide whether the color is a palette color. + assertTrue(condition("fill", "#6c707e").matches("#6c707eff")) + assertTrue(condition("fill", "#6c707e").matches("#6C707E80")) + assertTrue(condition("fill", "#ffffff").matches("#fff8")) + } + + @Test + fun `a hex literal of no valid length names no color`() { + // There is no five- or seven-digit hex color; such a value is not a color and must not be expanded into one. + assertFalse(condition("fill", "#6c707e").matches("#6c707")) + assertFalse(condition("fill", "#ffffff").matches("#fffff")) + assertFalse(condition("fill", "#ffffff").matches("#ff")) + } + @Test fun `different colors do not match`() { assertFalse(condition("fill", "#6c707e").matches("#818594")) assertFalse(condition("fill", "white").matches("black")) + assertFalse(condition("fill", "#ffffff").matches("#fffffe")) } @Test diff --git a/platform/icons-impl/src/com/intellij/platform/icons/impl/patchers/StrokePatcher.kt b/platform/icons-impl/src/com/intellij/platform/icons/impl/patchers/StrokePatcher.kt index dd045b4d49cc..f2f9cfffc784 100644 --- a/platform/icons-impl/src/com/intellij/platform/icons/impl/patchers/StrokePatcher.kt +++ b/platform/icons-impl/src/com/intellij/platform/icons/impl/patchers/StrokePatcher.kt @@ -38,6 +38,44 @@ fun strokeSvgPatcher(stroke: Color): SvgPatcher = svgPatcher { } } +/** + * The SVG patch for an icon that ships a hand-authored stroke variant, rendering it in [stroke]. + * + * Such a variant is a separate drawing of the same glyph, already reduced to an outline by hand and authored in white: + * it needs recoloring, not the palette reduction [strokeSvgPatcher] performs. Reducing it a second time could only take + * away artwork the author put there deliberately, since a tint that reads as a background in a filled icon can be the + * outline itself in a drawing made of outlines. + * + * Rendering one in opaque white patches nothing at all: the drawing is already that color, so it is left exactly as + * authored, down to any opacity it carries. Recoloring it white instead would be a substitution of a color by itself + * that still normalizes that opacity away. + */ +@ApiStatus.Internal +fun authoredStrokeSvgPatcher(stroke: Color): SvgPatcher = svgPatcher { + val target = stroke.toHex() + if (target.equals(OPAQUE_WHITE, ignoreCase = true)) return@svgPatcher + + for (color in authoredStrokePalette) { + replaceIfMatches("fill", color, target) + replaceIfMatches("stroke", color, target) + } +} + +/** + * The file-name suffix marking a hand-authored stroke variant, as in `run.svg` and its `run_stroke.svg`. + * + * Every frontend resolves the variant by this same suffix, so that stroking an icon reaches the same file whichever one + * renders it. + */ +@ApiStatus.Internal +const val AUTHORED_STROKE_VARIANT_SUFFIX: String = "_stroke" + +/** The only color a hand-authored stroke variant is drawn in, in both spellings an SVG may use for it. */ +private val authoredStrokePalette: List = listOf("white", "#ffffff") + +/** The hex [Color.toHex] produces for fully opaque white; a translucent white carries an alpha component instead. */ +private const val OPAQUE_WHITE: String = "#ffffff" + /** * Foreground palette entries an SVG may spell as a color keyword rather than as hex. * diff --git a/platform/icons-impl/src/com/intellij/platform/icons/impl/patchers/SvgAttributeWriter.kt b/platform/icons-impl/src/com/intellij/platform/icons/impl/patchers/SvgAttributeWriter.kt new file mode 100644 index 000000000000..c69e501d0ed2 --- /dev/null +++ b/platform/icons-impl/src/com/intellij/platform/icons/impl/patchers/SvgAttributeWriter.kt @@ -0,0 +1,70 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.platform.icons.impl.patchers + +import org.jetbrains.annotations.ApiStatus + +/** + * Writes [value] into [attributeName], keeping a color attribute and its paired opacity attribute consistent. + * + * A color attribute carries no alpha channel: alpha belongs on `fill-opacity` and its siblings. A color written into + * one is therefore split across the pair, and the opacity the document was authored with gives way to the one being + * written — it described the color that has just been replaced, not the new one. + * + * Writing lives here rather than in each renderer for the same reason condition evaluation lives on the operation: a + * frontend that split the pair differently would draw the same icon differently. + */ +@ApiStatus.Internal +fun writeSvgAttribute( + attributeName: String, + value: String, + setAttribute: (String, String) -> Unit, + removeAttribute: (String) -> Unit, +) { + val opacityAttributeName = OPACITY_ATTRIBUTES[attributeName] + val color = value.toOpaqueHexAndAlpha() + if (opacityAttributeName == null || color == null) { + setAttribute(attributeName, value) + return + } + + val (rgb, alpha) = color + setAttribute(attributeName, rgb) + if (alpha == OPAQUE) { + removeAttribute(opacityAttributeName) + } else { + setAttribute(opacityAttributeName, (alpha / OPAQUE.toFloat()).toString()) + } +} + +/** The color attributes that have a paired opacity attribute, and the attribute that holds it. */ +private val OPACITY_ATTRIBUTES = + mapOf( + "fill" to "fill-opacity", + "stroke" to "stroke-opacity", + "stop-color" to "stop-opacity", + "flood-color" to "flood-opacity", + ) + +private const val OPAQUE = 0xFF + +/** + * This hex literal split into the lowercase `#rrggbb` it names and its alpha, or `null` when it names no color. + * + * A color carries 3, 4, 6 or 8 significant digits, with the two shorthands standing for doubled pairs; an omitted + * alpha is fully opaque. + */ +private fun String.toOpaqueHexAndAlpha(): Pair? { + if (!startsWith('#')) return null + + val digits = drop(1).lowercase() + if (!digits.all { it in '0'..'9' || it in 'a'..'f' }) return null + + val expanded = + when (digits.length) { + 3, 4 -> digits.map { "$it$it" }.joinToString(separator = "") + 6, 8 -> digits + else -> return null + } + val alpha = if (expanded.length == 8) expanded.substring(6).toInt(radix = 16) else OPAQUE + return "#${expanded.substring(0, 6)}" to alpha +} diff --git a/platform/icons-impl/src/com/intellij/platform/icons/impl/patchers/SvgPatcher.kt b/platform/icons-impl/src/com/intellij/platform/icons/impl/patchers/SvgPatcher.kt index 582d3642b0f7..4aa7e65bf1b8 100644 --- a/platform/icons-impl/src/com/intellij/platform/icons/impl/patchers/SvgPatcher.kt +++ b/platform/icons-impl/src/com/intellij/platform/icons/impl/patchers/SvgPatcher.kt @@ -79,10 +79,10 @@ class SvgPatchOperation( /** * Whether [actualValue], the attribute's current value, satisfies this operation's condition. * - * A plain color compares case-insensitively, because hex colors and keywords are case-insensitive in SVG: - * `#6C707E` and `#6c707e` are the same color, and which one a document uses is an authoring accident. Everything - * else compares exactly — an `id`, and equally the fragment in a `fill="url(#Gradient)"` paint reference, is - * case-sensitive, so folding case there would match a different paint server. + * A plain color compares as a color, because SVG lets one color be written several ways and which one a document + * uses is an authoring accident: `#6C707E` and `#6c707e` are the same color, and so are `#fff` and `#ffffff`. + * Everything else compares exactly — an `id`, and equally the fragment in a `fill="url(#Gradient)"` paint + * reference, is case-sensitive, so folding case there would match a different paint server. * * Condition evaluation lives here rather than in each renderer so that every frontend resolves the same operation * the same way; a renderer that compared differently would draw the same icon differently. @@ -90,7 +90,7 @@ class SvgPatchOperation( @ApiStatus.Internal fun matches(actualValue: String?): Boolean = if (attributeName in COLOR_ATTRIBUTES && isPlainColor(expectedValue) && isPlainColor(actualValue)) { - actualValue.equals(expectedValue, ignoreCase = true) + sameColor(actualValue, expectedValue) } else { actualValue == expectedValue } @@ -149,3 +149,38 @@ private val COLOR_KEYWORD = Regex("[a-zA-Z]+(-[a-zA-Z]+)*") */ private fun isPlainColor(value: String?): Boolean = value != null && (HEX_COLOR.matches(value) || COLOR_KEYWORD.matches(value)) + +/** + * Whether two plain color literals denote the same color. + * + * Hex literals compare canonically, so a shorthand names the same color as the form it stands for. Any alpha the + * literal carries is not part of which color it names, because a substitution replaces the opacity of what it matched + * along with the color itself. A keyword, and a hex literal of a length that names no color, compares + * case-insensitively as text. + */ +private fun sameColor(actualValue: String?, expectedValue: String?): Boolean { + val actual = actualValue?.toRgbHex() + val expected = expectedValue?.toRgbHex() + return if (actual != null && expected != null) { + actual == expected + } else { + actualValue.equals(expectedValue, ignoreCase = true) + } +} + +/** + * The lowercase `#rrggbb` this hex literal names, or `null` when it names no color. + * + * A color carries 3, 4, 6 or 8 significant digits — the lengths `ColorHexUtil` accepts — with the two shorthands + * standing for doubled pairs. There is no valid five- or seven-digit form. + */ +private fun String.toRgbHex(): String? { + if (!startsWith('#')) return null + + val digits = drop(1).lowercase() + return when (digits.length) { + 3, 4 -> "#" + digits.take(3).map { "$it$it" }.joinToString(separator = "") + 6, 8 -> "#" + digits.take(6) + else -> null + } +} diff --git a/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/ComposeImageResourceProvider.kt b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/ComposeImageResourceProvider.kt index a9b324eabc89..5d1d882509bd 100644 --- a/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/ComposeImageResourceProvider.kt +++ b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/ComposeImageResourceProvider.kt @@ -6,13 +6,14 @@ import androidx.compose.ui.unit.Density import com.intellij.platform.icons.ImageResourceLocation import com.intellij.platform.icons.impl.patchers.DefaultSvgPatcher import com.intellij.platform.icons.impl.patchers.SvgPatchOperation +import com.intellij.platform.icons.impl.patchers.authoredStrokeSvgPatcher import com.intellij.platform.icons.impl.patchers.strokeSvgPatcher +import com.intellij.platform.icons.impl.patchers.writeSvgAttribute import com.intellij.platform.icons.impl.rendering.DefaultImageModifiers import com.intellij.platform.icons.rendering.ImageModifiers import com.intellij.platform.icons.rendering.ImageResource import com.intellij.platform.icons.rendering.ImageResourceProvider import java.io.ByteArrayInputStream -import java.io.InputStream import javax.xml.XMLConstants import javax.xml.parsers.DocumentBuilderFactory import org.jetbrains.annotations.ApiStatus @@ -27,16 +28,15 @@ public class ComposeImageResourceProvider : ImageResourceProvider { override fun loadImage(location: ImageResourceLocation, imageModifiers: ImageModifiers?): ImageResource { if (location is PathImageResourceLocation) { val extension = location.path.substringAfterLast(".").lowercase() - val data = location.loadData(imageModifiers) - val stream = ByteArrayInputStream(data) + val resolved = location.resolve(imageModifiers) return when (extension) { "svg" -> ComposePainterImageResource( - patchSvg(imageModifiers, stream).decodeToSvgPainter(Density(1f)), + patchSvg(imageModifiers, resolved).decodeToSvgPainter(Density(1f)), imageModifiers, ) // "xml" -> loader.loadData().decodeToImageVector() - else -> ComposeBitmapImageResource(location.loadData(imageModifiers).decodeToImageBitmap()) + else -> ComposeBitmapImageResource(resolved.data.decodeToImageBitmap()) } } else { error("Unsupported loader: $location") @@ -47,14 +47,18 @@ public class ComposeImageResourceProvider : ImageResourceProvider { private val documentBuilderFactory = DocumentBuilderFactory.newDefaultInstance().apply { setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true) } -private fun patchSvg(modifiers: ImageModifiers?, inputStream: InputStream): ByteArray { +private fun patchSvg(modifiers: ImageModifiers?, resolved: ResolvedImageResource): ByteArray { val builder = documentBuilderFactory.newDocumentBuilder() - val document = builder.parse(inputStream) + val document = builder.parse(ByteArrayInputStream(resolved.data)) val knownModifiers = modifiers as? DefaultImageModifiers // The palette substitution lives in the platform, so this frontend and the Swing one cannot drift into stroking - // the same icon differently. - val strokePatcher = knownModifiers?.stroke?.let { strokeSvgPatcher(it) } + // the same icon differently. Which of the two stroke patches applies follows from the file that was resolved: a + // hand-authored stroke variant is recolored as it is, while a base icon is reduced to an outline. + val strokePatcher = + knownModifiers?.stroke?.let { + if (resolved.isAuthoredStrokeVariant) authoredStrokeSvgPatcher(it) else strokeSvgPatcher(it) + } // Same order as the Swing frontend: the icon's own patcher first, the stroke substitution after it. The elvis // keeps a stroke-only icon patched, since `svgPatcher` is null whenever an icon carries no explicit patcher. val patcher = modifiers?.svgPatcher?.combineWith(strokePatcher) ?: strokePatcher @@ -65,13 +69,17 @@ private fun patchSvg(modifiers: ImageModifiers?, inputStream: InputStream): Byte /** The attribute's value, or `null` when the element does not carry it — DOM reports both as an empty string. */ private fun Element.attributeOrNull(name: String): String? = if (hasAttribute(name)) getAttribute(name) else null +private fun Element.writeAttribute(name: String, value: String) { + writeSvgAttribute(name, value, { attribute, written -> setAttribute(attribute, written) }, { removeAttribute(it) }) +} + @Suppress("NestedBlockDepth", "UnsafeCallOnNullableType") private fun DefaultSvgPatcher.patch(element: Element) { for (operation in operations) { when (operation.operation) { SvgPatchOperation.Operation.Add -> { if (!element.hasAttribute(operation.attributeName)) { - element.setAttribute(operation.attributeName, operation.value!!) + element.writeAttribute(operation.attributeName, operation.value!!) } } SvgPatchOperation.Operation.Replace -> { @@ -83,7 +91,7 @@ private fun DefaultSvgPatcher.patch(element: Element) { operation.matches(element.attributeOrNull(operation.attributeName)) != operation.negatedCondition) ) { - element.setAttribute(operation.attributeName, operation.value!!) + element.writeAttribute(operation.attributeName, operation.value!!) } } SvgPatchOperation.Operation.Remove -> { @@ -98,7 +106,7 @@ private fun DefaultSvgPatcher.patch(element: Element) { element.removeAttribute(operation.attributeName) } } - SvgPatchOperation.Operation.Set -> element.setAttribute(operation.attributeName, operation.value!!) + SvgPatchOperation.Operation.Set -> element.writeAttribute(operation.attributeName, operation.value!!) } } val nodes = element.childNodes diff --git a/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/PathImageResourceLocation.kt b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/PathImageResourceLocation.kt index 817db8cbb1b5..f22a8c5e94ef 100644 --- a/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/PathImageResourceLocation.kt +++ b/platform/jewel/ui/src/main/kotlin/org/jetbrains/jewel/ui/icon/PathImageResourceLocation.kt @@ -2,40 +2,78 @@ package org.jetbrains.jewel.ui.icon import com.intellij.platform.icons.ImageResourceLocation +import com.intellij.platform.icons.impl.patchers.AUTHORED_STROKE_VARIANT_SUFFIX import com.intellij.platform.icons.impl.rendering.DefaultImageModifiers import com.intellij.platform.icons.rendering.ImageModifiers import org.jetbrains.annotations.ApiStatus import org.jetbrains.jewel.foundation.InternalJewelApi // TODO: Replace with ModuleImageResourceLocation and custom Loader for it +/** + * An [ImageResourceLocation] that loads image bytes from a classpath resource at [path] using [classLoader], applying + * dark-mode path modifiers. + */ @InternalJewelApi @ApiStatus.Internal public class PathImageResourceLocation(public val path: String, public val classLoader: ClassLoader?) : ImageResourceLocation { - public fun loadData(imageModifiers: ImageModifiers?): ByteArray { - val knownMods = imageModifiers as? DefaultImageModifiers - val finalPath = applyPathModifiers(path, knownMods) - val resourceStream = - if (classLoader != null) { - classLoader.getResourceAsStream(finalPath) - } else ClassLoader.getSystemResourceAsStream(path) - return resourceStream?.readBytes() ?: error("Resource not found: $finalPath") + /** + * Loads and returns the raw bytes of the image resource, applying dark-mode path modifiers from [imageModifiers] if + * present. + * + * @param imageModifiers Optional image modifiers (e.g., dark mode) used to select the correct resource path. + */ + public fun loadData(imageModifiers: ImageModifiers?): ByteArray = resolve(imageModifiers).data + + /** Resolves which of this location's files [imageModifiers] calls for, and reads it. */ + internal fun resolve(imageModifiers: ImageModifiers?): ResolvedImageResource { + val knownMods = + imageModifiers as? DefaultImageModifiers + ?: return ResolvedImageResource( + data = readOrNull(path) ?: error("Resource not found: $path"), + isAuthoredStrokeVariant = false, + ) + return resolve(stroked = knownMods.stroke != null, isDark = knownMods.isDark) } - private fun applyPathModifiers(path: String, modifiers: DefaultImageModifiers?): String { - if (modifiers == null) return path - return buildString { - append(path.substringBeforeLast('/', "")) - append('/') - append(path.substringBeforeLast('.').substringAfterLast('/')) - // A stroked icon loads its light artwork even in a dark theme, matching the Swing frontend: the stroke - // palette describes the light variants, so a `_dark` variant would come back with colors it does not know - // and would be left in its authored color instead of being recolored. - if (modifiers.isDark && modifiers.stroke == null) { - append("_dark") - } - append('.') - append(path.substringAfterLast('.')) + /** + * Resolves the file a stroked or dark icon is read from. + * + * A stroked icon prefers its hand-authored stroke variant, which is a separate drawing of the same glyph rather + * than a recolor of this one, and falls back to the base file for the icons that ship no such variant. Either way + * it loads light artwork, matching the Swing frontend: the stroke palette describes the light variants, so a + * `_dark` variant would come back with colors it does not know and would be left in its authored color instead of + * being recolored. + */ + internal fun resolve(stroked: Boolean, isDark: Boolean): ResolvedImageResource { + if (stroked) { + val strokePath = applyPathModifiers(path, AUTHORED_STROKE_VARIANT_SUFFIX) + val strokeData = readOrNull(strokePath) + if (strokeData != null) return ResolvedImageResource(strokeData, isAuthoredStrokeVariant = true) } + + val finalPath = applyPathModifiers(path, if (isDark && !stroked) "_dark" else "") + val data = readOrNull(finalPath) ?: error("Resource not found: $finalPath") + return ResolvedImageResource(data, isAuthoredStrokeVariant = false) + } + + private fun applyPathModifiers(path: String, suffix: String): String = buildString { + append(path.substringBeforeLast('/', "")) + append('/') + append(path.substringBeforeLast('.').substringAfterLast('/')) + append(suffix) + append('.') + append(path.substringAfterLast('.')) + } + + private fun readOrNull(path: String): ByteArray? { + val resourceStream = + if (classLoader != null) { + classLoader.getResourceAsStream(path) + } else ClassLoader.getSystemResourceAsStream(path) + return resourceStream?.use { it.readBytes() } } } + +/** The bytes a [PathImageResourceLocation] resolved to, and which of its files they came from. */ +internal class ResolvedImageResource(val data: ByteArray, val isAuthoredStrokeVariant: Boolean) diff --git a/platform/jewel/ui/src/test/kotlin/org/jetbrains/jewel/ui/icon/PathImageResourceLocationTest.kt b/platform/jewel/ui/src/test/kotlin/org/jetbrains/jewel/ui/icon/PathImageResourceLocationTest.kt new file mode 100644 index 000000000000..87ce3666cf7d --- /dev/null +++ b/platform/jewel/ui/src/test/kotlin/org/jetbrains/jewel/ui/icon/PathImageResourceLocationTest.kt @@ -0,0 +1,67 @@ +// Copyright 2000-2026 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.jewel.ui.icon + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pins which file a stroked icon is read from. + * + * An icon may ship a hand-authored stroke variant: a separate drawing of the same glyph, already reduced to an outline. + * Recoloring the base drawing is not a substitute for it — `strokeVariant.svg` is a filled shape inside an outline + * whose color is outside the stroke palette, so no palette substitution turns it into the outlined glyph that + * `strokeVariant_stroke.svg` holds. Icons that ship no such variant have to keep resolving to their base file. + */ +internal class PathImageResourceLocationTest { + @Test + fun `a stroked icon resolves its hand-authored stroke variant`() { + val resolved = location("icons/strokeVariant.svg").resolve(stroked = true, isDark = false) + + assertTrue(resolved.isAuthoredStrokeVariant) + assertEquals(contentOf("icons/strokeVariant_stroke.svg"), resolved.data.decodeToString()) + } + + @Test + fun `a stroked icon without a variant falls back to its base file`() { + val resolved = location("icons/search.svg").resolve(stroked = true, isDark = false) + + assertFalse(resolved.isAuthoredStrokeVariant) + assertEquals(contentOf("icons/search.svg"), resolved.data.decodeToString()) + } + + @Test + fun `a stroked icon takes light artwork in a dark theme`() { + // The stroke palette describes the light variants, so a stroked icon resolves the variant rather than `_dark` + // — and falls back to the light base file, not the dark one, when it ships no variant. + assertTrue(location("icons/strokeVariant.svg").resolve(stroked = true, isDark = true).isAuthoredStrokeVariant) + assertEquals( + contentOf("icons/search.svg"), + location("icons/search.svg").resolve(stroked = true, isDark = true).data.decodeToString(), + ) + } + + @Test + fun `a dark icon that is not stroked still resolves its dark variant`() { + val resolved = location("icons/search.svg").resolve(stroked = false, isDark = true) + + assertFalse(resolved.isAuthoredStrokeVariant) + assertEquals(contentOf("icons/search_dark.svg"), resolved.data.decodeToString()) + } + + @Test + fun `an icon that is not stroked never resolves the variant`() { + val resolved = location("icons/strokeVariant.svg").resolve(stroked = false, isDark = false) + + assertFalse(resolved.isAuthoredStrokeVariant) + assertEquals(contentOf("icons/strokeVariant.svg"), resolved.data.decodeToString()) + } + + private fun location(path: String) = PathImageResourceLocation(path, javaClass.classLoader) + + private fun contentOf(path: String): String = + checkNotNull(javaClass.classLoader.getResourceAsStream(path)) { "missing test resource: $path" } + .use { it.readBytes() } + .decodeToString() +} diff --git a/platform/jewel/ui/src/test/resources/icons/strokeVariant.svg b/platform/jewel/ui/src/test/resources/icons/strokeVariant.svg new file mode 100644 index 000000000000..4081872d54be --- /dev/null +++ b/platform/jewel/ui/src/test/resources/icons/strokeVariant.svg @@ -0,0 +1,4 @@ + + + + diff --git a/platform/jewel/ui/src/test/resources/icons/strokeVariant_stroke.svg b/platform/jewel/ui/src/test/resources/icons/strokeVariant_stroke.svg new file mode 100644 index 000000000000..fea00aec867c --- /dev/null +++ b/platform/jewel/ui/src/test/resources/icons/strokeVariant_stroke.svg @@ -0,0 +1,4 @@ + + + +