Skip to content

Commit 0879b76

Browse files
t-regbsegorikftpvkatz
authored
[Plugin] Add Lucide Icons Web Import Feature (#781)
* feat: add initial working implementation for lucide web import * chore: update abi and address linting/review issue * refactor: replace test dependencies with bundle and secure transformer against xxe attacks * refactor: replace custom LruCache with androidx collection version * chore: fix linting * Reuse json and httpClient across web import features * Create NoStopIndicatorSlider * refactor: make lucide icons reparse on slider drag end and chnged max size to 48 * refactor: stop icon from being clickable when loading and fix performance issues * refactor: add locale to string format and fix potential race condition * refactor: implement singleflight mechanism for concurrent icon load deduplication * update svg customization to use latest settings from lucideRecord * Migrate to Jewel components * refactor: use ttf font for lucide icons * refactor: remove error state update on font load failure in lucide viewmodel * chore: remove redundant import of toGridItems in lucide viewmodel * Correct slider value change handling * Extract LucideIconGrid and MaterialIconGrid * Reformat initial state * Remove unused schema * Reuse FontByteArray model * Remove SingleFlight * refactor: extracted fuzzy search grid filter logic and share between lucide and material --------- Co-authored-by: Yahor Urbanovich <egorikftp@gmail.com> Co-authored-by: Viachaslau Katsuba <vkatsubo@gmail.com>
1 parent 1c84642 commit 0879b76

39 files changed

Lines changed: 1669 additions & 148 deletions

File tree

components/parser/jvm/svg/api/svg.api

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
public final class io/github/composegears/valkyrie/parser/jvm/svg/SvgManipulator {
2+
public static final field INSTANCE Lio/github/composegears/valkyrie/parser/jvm/svg/SvgManipulator;
3+
public final fun modifySvg (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)Ljava/lang/String;
4+
public final fun updateAttributeConditionally (Lorg/w3c/dom/Element;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V
5+
public final fun updateAttributeRecursively (Lorg/w3c/dom/Element;Ljava/lang/String;Ljava/lang/String;)V
6+
}
7+
18
public final class io/github/composegears/valkyrie/parser/jvm/svg/SvgToXmlParser {
29
public static final field INSTANCE Lio/github/composegears/valkyrie/parser/jvm/svg/SvgToXmlParser;
310
public final fun parse (Ljava/lang/String;)Ljava/lang/String;

components/parser/jvm/svg/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,6 @@ plugins {
66

77
dependencies {
88
implementation(libs.android.build.tools)
9+
10+
testImplementation(libs.bundles.kmp.test)
911
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package io.github.composegears.valkyrie.parser.jvm.svg
2+
3+
import java.io.StringReader
4+
import java.io.StringWriter
5+
import javax.xml.parsers.DocumentBuilderFactory
6+
import javax.xml.transform.OutputKeys
7+
import javax.xml.transform.TransformerFactory
8+
import javax.xml.transform.dom.DOMSource
9+
import javax.xml.transform.stream.StreamResult
10+
import org.w3c.dom.Element
11+
12+
/**
13+
* Utility for manipulating SVG content using DOM-based XML parsing.
14+
*
15+
* This provides robust SVG modification capabilities that handle:
16+
* - Attribute spacing and quote variations
17+
* - Attribute ordering differences
18+
* - Multiple occurrences of attributes across nested elements
19+
*/
20+
object SvgManipulator {
21+
22+
/**
23+
* Applies attribute modifications to SVG content.
24+
*
25+
* @param svgContent The original SVG content as a string
26+
* @param modifications Lambda that receives the root SVG element for modification
27+
* @return Modified SVG content, or original content if parsing fails
28+
*/
29+
fun modifySvg(
30+
svgContent: String,
31+
modifications: (Element) -> Unit,
32+
): String {
33+
return try {
34+
val factory = DocumentBuilderFactory.newInstance()
35+
factory.apply {
36+
setFeature("http://apache.org/xml/features/disallow-doctype-decl", true)
37+
setFeature("http://xml.org/sax/features/external-general-entities", false)
38+
setFeature("http://xml.org/sax/features/external-parameter-entities", false)
39+
}
40+
val builder = factory.newDocumentBuilder()
41+
val document = builder.parse(org.xml.sax.InputSource(StringReader(svgContent)))
42+
43+
val svgElement = document.documentElement
44+
modifications(svgElement)
45+
46+
val transformerFactory = TransformerFactory.newInstance().apply {
47+
setAttribute(javax.xml.XMLConstants.ACCESS_EXTERNAL_DTD, "")
48+
setAttribute(javax.xml.XMLConstants.ACCESS_EXTERNAL_STYLESHEET, "")
49+
}
50+
val transformer = transformerFactory.newTransformer()
51+
transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes")
52+
val writer = StringWriter()
53+
transformer.transform(DOMSource(document), StreamResult(writer))
54+
writer.toString()
55+
} catch (e: Exception) {
56+
println("Failed to parse SVG for modification: ${e.message}")
57+
svgContent
58+
}
59+
}
60+
61+
/**
62+
* Updates an attribute on all elements in the tree that have it.
63+
*
64+
* @param element Root element to start searching from
65+
* @param attributeName Name of the attribute to update
66+
* @param newValue New value for the attribute
67+
*/
68+
fun updateAttributeRecursively(
69+
element: Element,
70+
attributeName: String,
71+
newValue: String,
72+
) {
73+
if (element.hasAttribute(attributeName)) {
74+
element.setAttribute(attributeName, newValue)
75+
}
76+
77+
val children = element.childNodes
78+
for (i in 0 until children.length) {
79+
val child = children.item(i)
80+
if (child is Element) {
81+
updateAttributeRecursively(child, attributeName, newValue)
82+
}
83+
}
84+
}
85+
86+
/**
87+
* Updates an attribute on all elements that have a specific current value.
88+
*
89+
* @param element Root element to start searching from
90+
* @param attributeName Name of the attribute to update
91+
* @param currentValue Current value to match
92+
* @param newValue New value for the attribute
93+
*/
94+
fun updateAttributeConditionally(
95+
element: Element,
96+
attributeName: String,
97+
currentValue: String,
98+
newValue: String,
99+
) {
100+
if (element.hasAttribute(attributeName)) {
101+
val current = element.getAttribute(attributeName)
102+
if (current == currentValue) {
103+
element.setAttribute(attributeName, newValue)
104+
}
105+
}
106+
107+
val children = element.childNodes
108+
for (i in 0 until children.length) {
109+
val child = children.item(i)
110+
if (child is Element) {
111+
updateAttributeConditionally(child, attributeName, currentValue, newValue)
112+
}
113+
}
114+
}
115+
}
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
package io.github.composegears.valkyrie.parser.jvm.svg
2+
3+
import assertk.assertThat
4+
import assertk.assertions.contains
5+
import assertk.assertions.doesNotContain
6+
import assertk.assertions.isEqualTo
7+
import kotlin.test.Test
8+
import kotlin.test.assertFalse
9+
import kotlin.test.assertTrue
10+
11+
class SvgManipulatorTest {
12+
13+
@Test
14+
fun `modifySvg updates root element attribute`() {
15+
val originalSvg = """
16+
<svg width="24" height="24" viewBox="0 0 24 24">
17+
<path d="M10,10h4v4h-4z"/>
18+
</svg>
19+
""".trimIndent()
20+
21+
val modifiedSvg = SvgManipulator.modifySvg(originalSvg) { svgElement ->
22+
svgElement.setAttribute("width", "48")
23+
svgElement.setAttribute("height", "48")
24+
}
25+
26+
assertThat(modifiedSvg).contains("width=\"48\"")
27+
assertThat(modifiedSvg).contains("height=\"48\"")
28+
assertThat(modifiedSvg).doesNotContain("width=\"24\"")
29+
assertThat(modifiedSvg).doesNotContain("height=\"24\"")
30+
}
31+
32+
@Test
33+
fun `updateAttributeRecursively updates all matching attributes`() {
34+
val originalSvg = """
35+
<svg width="24" height="24" stroke-width="2">
36+
<g stroke-width="2">
37+
<path stroke-width="2" d="M10,10h4v4h-4z"/>
38+
<circle stroke-width="2" cx="12" cy="12" r="5"/>
39+
</g>
40+
</svg>
41+
""".trimIndent()
42+
43+
val modifiedSvg = SvgManipulator.modifySvg(originalSvg) { svgElement ->
44+
SvgManipulator.updateAttributeRecursively(
45+
element = svgElement,
46+
attributeName = "stroke-width",
47+
newValue = "3",
48+
)
49+
}
50+
51+
assertThat(modifiedSvg).contains("stroke-width=\"3\"")
52+
assertThat(modifiedSvg).doesNotContain("stroke-width=\"2\"")
53+
54+
val occurrences = modifiedSvg.split("stroke-width=\"3\"").size - 1
55+
assertThat(occurrences).isEqualTo(4)
56+
}
57+
58+
@Test
59+
fun `updateAttributeRecursively does not affect elements without attribute`() {
60+
val originalSvg = """
61+
<svg width="24" height="24" stroke-width="2">
62+
<path d="M10,10h4v4h-4z"/>
63+
<circle stroke-width="2" cx="12" cy="12" r="5"/>
64+
</svg>
65+
""".trimIndent()
66+
67+
val modifiedSvg = SvgManipulator.modifySvg(originalSvg) { svgElement ->
68+
SvgManipulator.updateAttributeRecursively(
69+
element = svgElement,
70+
attributeName = "stroke-width",
71+
newValue = "3",
72+
)
73+
}
74+
75+
assertThat(modifiedSvg).contains("<path d=\"M10,10h4v4h-4z\"/>")
76+
assertThat(modifiedSvg).contains("stroke-width=\"3\"")
77+
}
78+
79+
@Test
80+
fun `updateAttributeConditionally only updates matching values`() {
81+
val originalSvg = """
82+
<svg stroke="currentColor">
83+
<path stroke="currentColor" d="M10,10h4v4h-4z"/>
84+
<circle stroke="#FF0000" cx="12" cy="12" r="5"/>
85+
<rect stroke="currentColor" x="0" y="0" width="10" height="10"/>
86+
</svg>
87+
""".trimIndent()
88+
89+
val modifiedSvg = SvgManipulator.modifySvg(originalSvg) { svgElement ->
90+
SvgManipulator.updateAttributeConditionally(
91+
element = svgElement,
92+
attributeName = "stroke",
93+
currentValue = "currentColor",
94+
newValue = "#00FF00",
95+
)
96+
}
97+
98+
assertThat(modifiedSvg).contains("stroke=\"#00FF00\"")
99+
assertThat(modifiedSvg).doesNotContain("stroke=\"currentColor\"")
100+
101+
assertThat(modifiedSvg).contains("stroke=\"#FF0000\"")
102+
103+
val greenCount = modifiedSvg.split("stroke=\"#00FF00\"").size - 1
104+
val redCount = modifiedSvg.split("stroke=\"#FF0000\"").size - 1
105+
assertThat(greenCount).isEqualTo(3)
106+
assertThat(redCount).isEqualTo(1)
107+
}
108+
109+
@Test
110+
fun `modifySvg handles nested elements correctly`() {
111+
val originalSvg = """
112+
<svg width="24">
113+
<g>
114+
<g>
115+
<path stroke-width="2" d="M10,10h4v4h-4z"/>
116+
</g>
117+
</g>
118+
</svg>
119+
""".trimIndent()
120+
121+
val modifiedSvg = SvgManipulator.modifySvg(originalSvg) { svgElement ->
122+
SvgManipulator.updateAttributeRecursively(
123+
element = svgElement,
124+
attributeName = "stroke-width",
125+
newValue = "5",
126+
)
127+
}
128+
129+
assertThat(modifiedSvg).contains("stroke-width=\"5\"")
130+
assertThat(modifiedSvg).doesNotContain("stroke-width=\"2\"")
131+
}
132+
133+
@Test
134+
fun `modifySvg handles different quote styles`() {
135+
val originalSvg = """
136+
<svg width='24' height="24">
137+
<path d="M10,10h4v4h-4z"/>
138+
</svg>
139+
""".trimIndent()
140+
141+
val modifiedSvg = SvgManipulator.modifySvg(originalSvg) { svgElement ->
142+
svgElement.setAttribute("width", "48")
143+
}
144+
145+
assertThat(modifiedSvg).contains("width=\"48\"")
146+
}
147+
148+
@Test
149+
fun `modifySvg handles attributes with spaces`() {
150+
val originalSvg = """
151+
<svg width = "24" height= "24">
152+
<path d="M10,10h4v4h-4z"/>
153+
</svg>
154+
""".trimIndent()
155+
156+
val modifiedSvg = SvgManipulator.modifySvg(originalSvg) { svgElement ->
157+
svgElement.setAttribute("width", "48")
158+
}
159+
160+
assertThat(modifiedSvg).contains("width=\"48\"")
161+
}
162+
163+
@Test
164+
fun `modifySvg returns original content on parse failure`() {
165+
val invalidSvg = "This is not valid XML"
166+
167+
val result = SvgManipulator.modifySvg(invalidSvg) { svgElement ->
168+
svgElement.setAttribute("width", "48")
169+
}
170+
171+
assertThat(result).isEqualTo(invalidSvg)
172+
}
173+
174+
@Test
175+
fun `modifySvg preserves content structure`() {
176+
val originalSvg = """
177+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
178+
<path d="M10,10h4v4h-4z"/>
179+
<circle cx="12" cy="12" r="10"/>
180+
</svg>
181+
""".trimIndent()
182+
183+
val modifiedSvg = SvgManipulator.modifySvg(originalSvg) { svgElement ->
184+
svgElement.setAttribute("width", "48")
185+
}
186+
187+
with(modifiedSvg) {
188+
assertTrue(contains("<svg"))
189+
assertTrue(contains("<path"))
190+
assertTrue(contains("<circle"))
191+
assertTrue(contains("</svg>"))
192+
}
193+
}
194+
195+
@Test
196+
fun `updateAttributeRecursively handles empty attribute value`() {
197+
val originalSvg = """
198+
<svg width="24">
199+
<path stroke-width="" d="M10,10h4v4h-4z"/>
200+
</svg>
201+
""".trimIndent()
202+
203+
val modifiedSvg = SvgManipulator.modifySvg(originalSvg) { svgElement ->
204+
SvgManipulator.updateAttributeRecursively(
205+
element = svgElement,
206+
attributeName = "stroke-width",
207+
newValue = "2",
208+
)
209+
}
210+
211+
assertThat(modifiedSvg).contains("stroke-width=\"2\"")
212+
}
213+
214+
@Test
215+
fun `complex real-world Lucide icon manipulation`() {
216+
val lucideSvg = """
217+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
218+
<circle cx="12" cy="12" r="10"/>
219+
<path d="M12 6v6l4 2"/>
220+
</svg>
221+
""".trimIndent()
222+
223+
val modifiedSvg = SvgManipulator.modifySvg(lucideSvg) { svgElement ->
224+
svgElement.setAttribute("width", "48")
225+
svgElement.setAttribute("height", "48")
226+
227+
SvgManipulator.updateAttributeRecursively(
228+
element = svgElement,
229+
attributeName = "stroke-width",
230+
newValue = "3",
231+
)
232+
233+
SvgManipulator.updateAttributeConditionally(
234+
element = svgElement,
235+
attributeName = "stroke",
236+
currentValue = "currentColor",
237+
newValue = "#FF5733",
238+
)
239+
}
240+
241+
with(modifiedSvg) {
242+
assertTrue(contains("width=\"48\""))
243+
assertTrue(contains("height=\"48\""))
244+
assertTrue(contains("stroke-width=\"3\""))
245+
assertTrue(contains("stroke=\"#FF5733\""))
246+
assertFalse(contains("stroke=\"currentColor\""))
247+
assertFalse(contains("stroke-width=\"2\""))
248+
}
249+
}
250+
}

gradle/libs.versions.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
[versions]
2+
collection = "1.5.0"
23
compose = "1.9.0"
34
intellij = "2.11.0"
45
jacoco = "0.8.13"
@@ -11,6 +12,7 @@ leviathan = "3.1.0"
1112
ktor = "3.3.1"
1213

1314
[libraries]
15+
androidx-collection = { module = "androidx.collection:collection", version.ref = "collection" }
1416
android-build-tools = "com.android.tools:sdk-common:31.13.2"
1517
fonticons = "dev.tclement.fonticons:core:2.1.1"
1618
fuzzysearch = "com.github.android-password-store:sublime-fuzzy:2.3.4"

0 commit comments

Comments
 (0)