From 6060e86cbdd125a42c4315dba8e641daf4bfad1a Mon Sep 17 00:00:00 2001 From: Nikita Lipsky Date: Tue, 14 Feb 2023 21:22:03 +0200 Subject: [PATCH] Support for XML Vector Drawables. --- .../demo/androidApp/build.gradle.kts | 7 +- .../resources/demo/shared/UseResources.kt | 6 + .../src/commonMain/resources/vector.xml | 34 +++ components/resources/library/build.gradle.kts | 8 +- .../resources/ComposeResource.common.kt | 56 +++- .../compose/resources/vector/ValueParsers.kt | 104 +++++++ .../resources/vector/XmlVectorParser.kt | 276 ++++++++++++++++++ .../resources/vector/xmldom/Element.kt | 11 + .../compose/resources/vector/xmldom/Node.kt | 14 + .../resources/vector/xmldom/NodeList.kt | 10 + .../compose/resources/Resource.ios.kt | 5 +- .../compose/resources/xmldom/DomXmlParser.kt | 114 ++++++++ .../compose/resources/Resource.js.kt | 5 + .../resources/Resource.jvmandandroid.kt | 22 ++ .../compose/resources/xmldom/ElementImpl.kt | 10 + .../compose/resources/xmldom/NodeImpl.kt | 28 ++ .../compose/resources/Resource.macos.kt | 5 + 17 files changed, 696 insertions(+), 19 deletions(-) create mode 100644 components/resources/demo/shared/src/commonMain/resources/vector.xml create mode 100644 components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/vector/ValueParsers.kt create mode 100644 components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/vector/XmlVectorParser.kt create mode 100644 components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/vector/xmldom/Element.kt create mode 100644 components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/vector/xmldom/Node.kt create mode 100644 components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/vector/xmldom/NodeList.kt create mode 100644 components/resources/library/src/iosMain/kotlin/org/jetbrains/compose/resources/xmldom/DomXmlParser.kt create mode 100644 components/resources/library/src/jvmAndAndroidMain/kotlin/org/jetbrains/compose/resources/Resource.jvmandandroid.kt create mode 100644 components/resources/library/src/jvmAndAndroidMain/kotlin/org/jetbrains/compose/resources/xmldom/ElementImpl.kt create mode 100644 components/resources/library/src/jvmAndAndroidMain/kotlin/org/jetbrains/compose/resources/xmldom/NodeImpl.kt diff --git a/components/resources/demo/androidApp/build.gradle.kts b/components/resources/demo/androidApp/build.gradle.kts index 4aa54613a1..56f228a7c8 100644 --- a/components/resources/demo/androidApp/build.gradle.kts +++ b/components/resources/demo/androidApp/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("com.android.application") kotlin("android") + id("org.jetbrains.compose") } dependencies { @@ -12,12 +13,6 @@ dependencies { } android { - buildFeatures { - compose = true - } - composeOptions { - kotlinCompilerExtensionVersion = "1.3.2" - } compileSdk = 33 defaultConfig { applicationId = "me.user.androidApp" diff --git a/components/resources/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/resources/demo/shared/UseResources.kt b/components/resources/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/resources/demo/shared/UseResources.kt index 9b89bae350..bc5f514132 100644 --- a/components/resources/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/resources/demo/shared/UseResources.kt +++ b/components/resources/demo/shared/src/commonMain/kotlin/org/jetbrains/compose/resources/demo/shared/UseResources.kt @@ -2,8 +2,10 @@ package org.jetbrains.compose.resources.demo.shared import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* +import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalDensity import org.jetbrains.compose.resources.* @OptIn(ExperimentalResourceApi::class) @@ -19,5 +21,9 @@ internal fun UseResources() { bitmap = resource("img.webp").rememberImageBitmap().orEmpty(), contentDescription = null, ) + Icon( + imageVector = resource("vector.xml").rememberImageVector(LocalDensity.current).orEmpty(), + contentDescription = null + ) } } diff --git a/components/resources/demo/shared/src/commonMain/resources/vector.xml b/components/resources/demo/shared/src/commonMain/resources/vector.xml new file mode 100644 index 0000000000..1f6bb29060 --- /dev/null +++ b/components/resources/demo/shared/src/commonMain/resources/vector.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + diff --git a/components/resources/library/build.gradle.kts b/components/resources/library/build.gradle.kts index 796ef6019e..bd26802d18 100644 --- a/components/resources/library/build.gradle.kts +++ b/components/resources/library/build.gradle.kts @@ -38,8 +38,12 @@ kotlin { val skikoMain by creating { dependsOn(commonMain) } + val jvmAndAndroidMain by creating { + dependsOn(commonMain) + } val desktopMain by getting { dependsOn(skikoMain) + dependsOn(jvmAndAndroidMain) } val desktopTest by getting { dependencies { @@ -48,7 +52,9 @@ kotlin { implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.6.4") } } - val androidMain by getting {} + val androidMain by getting { + dependsOn(jvmAndAndroidMain) + } val androidTest by getting { dependencies { diff --git a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ComposeResource.common.kt b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ComposeResource.common.kt index f39c1fcde1..c09f0f2b6a 100644 --- a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ComposeResource.common.kt +++ b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/ComposeResource.common.kt @@ -7,19 +7,25 @@ package org.jetbrains.compose.resources import androidx.compose.runtime.* import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.Density +import org.jetbrains.compose.resources.vector.xmldom.Element +import org.jetbrains.compose.resources.vector.parseVectorRoot +import androidx.compose.ui.unit.dp private val emptyImageBitmap: ImageBitmap by lazy { ImageBitmap(1, 1) } -/** - * Get and remember resource. While loading and if resource not exists result will be null. - */ -@ExperimentalResourceApi +private val emptyImageVector: ImageVector by lazy { + ImageVector.Builder(defaultWidth = 1.dp, defaultHeight = 1.dp, viewportWidth = 1f, viewportHeight = 1f).build() +} + +@OptIn(ExperimentalResourceApi::class) @Composable -fun Resource.rememberImageBitmap(): LoadState { - val state: MutableState> = remember(this) { mutableStateOf(LoadState.Loading()) } +private fun Resource.rememberLoadingResource(fromByteArrayConverter: ByteArray.()->T): LoadState { + val state: MutableState> = remember(this) { mutableStateOf(LoadState.Loading()) } LaunchedEffect(this) { state.value = try { - LoadState.Success(readBytes().toImageBitmap()) + LoadState.Success(readBytes().fromByteArrayConverter()) } catch (e: Exception) { LoadState.Error(e) } @@ -28,13 +34,41 @@ fun Resource.rememberImageBitmap(): LoadState { } /** - * return current ImageBitmap or return empty while loading + * Get and remember resource. While loading and if resource not exists result will be null. + */ +@ExperimentalResourceApi +@Composable +fun Resource.rememberImageBitmap(): LoadState = + rememberLoadingResource { toImageBitmap() } + +/** + * Get and remember resource. While loading and if resource not exists result will be null. */ @ExperimentalResourceApi -fun LoadState.orEmpty(): ImageBitmap = when (this) { - is LoadState.Loading -> emptyImageBitmap +@Composable +fun Resource.rememberImageVector(density: Density): LoadState = + rememberLoadingResource { toImageVector(density) } + +private fun LoadState.orEmpty(emptyValue: T): T = when (this) { + is LoadState.Loading -> emptyValue is LoadState.Success -> this.value - is LoadState.Error -> emptyImageBitmap + is LoadState.Error -> emptyValue } +/** + * return current ImageBitmap or return empty while loading + */ +@ExperimentalResourceApi +fun LoadState.orEmpty(): ImageBitmap = orEmpty(emptyImageBitmap) + +/** + * return current ImageVector or return empty while loading + */ +@ExperimentalResourceApi +fun LoadState.orEmpty(): ImageVector = orEmpty(emptyImageVector) + internal expect fun ByteArray.toImageBitmap(): ImageBitmap + +internal expect fun parseXML(byteArray: ByteArray): Element + +internal fun ByteArray.toImageVector(density: Density): ImageVector = parseXML(this).parseVectorRoot(density) diff --git a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/vector/ValueParsers.kt b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/vector/ValueParsers.kt new file mode 100644 index 0000000000..cf065f4bfc --- /dev/null +++ b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/vector/ValueParsers.kt @@ -0,0 +1,104 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jetbrains.compose.resources.vector + +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +private const val ALPHA_MASK = 0xFF000000.toInt() + +// parseColorValue is copied from Android: +// https://cs.android.com/android-studio/platform/tools/base/+/05fadd8cb2aaafb77da02048c7a240b2147ff293:sdk-common/src/main/java/com/android/ide/common/vectordrawable/VdUtil.kt;l=58 +/** + * Parses a color value in #AARRGGBB format. + * + * @param color the color value string + * @return the integer color value + */ +internal fun parseColorValue(color: String): Int { + require(color.startsWith("#")) { "Invalid color value $color" } + + return when (color.length) { + 7 -> { + // #RRGGBB + color.substring(1).toUInt(16).toInt() or ALPHA_MASK + } + 9 -> { + // #AARRGGBB + color.substring(1).toUInt(16).toInt() + } + 4 -> { + // #RGB + val v = color.substring(1).toUInt(16).toInt() + var k = (v shr 8 and 0xF) * 0x110000 + k = k or (v shr 4 and 0xF) * 0x1100 + k = k or (v and 0xF) * 0x11 + k or ALPHA_MASK + } + 5 -> { + // #ARGB + val v = color.substring(1).toUInt(16).toInt() + var k = (v shr 12 and 0xF) * 0x11000000 + k = k or (v shr 8 and 0xF) * 0x110000 + k = k or (v shr 4 and 0xF) * 0x1100 + k = k or (v and 0xF) * 0x11 + k or ALPHA_MASK + } + else -> ALPHA_MASK + } +} + +internal fun parseFillType(fillType: String): PathFillType = when (fillType) { + "nonZero" -> PathFillType.NonZero + "evenOdd" -> PathFillType.EvenOdd + else -> throw UnsupportedOperationException("unknown fillType: $fillType") +} + +internal fun parseStrokeCap(strokeCap: String): StrokeCap = when (strokeCap) { + "butt" -> StrokeCap.Butt + "round" -> StrokeCap.Round + "square" -> StrokeCap.Square + else -> throw UnsupportedOperationException("unknown strokeCap: $strokeCap") +} + +internal fun parseStrokeJoin(strokeJoin: String): StrokeJoin = when (strokeJoin) { + "miter" -> StrokeJoin.Miter + "round" -> StrokeJoin.Round + "bevel" -> StrokeJoin.Bevel + else -> throw UnsupportedOperationException("unknown strokeJoin: $strokeJoin") +} + +internal fun parseTileMode(tileMode: String): TileMode = when (tileMode) { + "clamp" -> TileMode.Clamp + "repeated" -> TileMode.Repeated + "mirror" -> TileMode.Mirror + else -> throw throw UnsupportedOperationException("unknown tileMode: $tileMode") +} + +internal fun String?.parseDp(density: Density): Dp = with(density) { + return when { + this@parseDp == null -> 0f.dp + endsWith("dp") -> removeSuffix("dp").toFloat().dp + endsWith("px") -> removeSuffix("px").toFloat().toDp() + else -> throw UnsupportedOperationException("value should ends with dp or px") + } +} diff --git a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/vector/XmlVectorParser.kt b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/vector/XmlVectorParser.kt new file mode 100644 index 0000000000..c5f142cbaa --- /dev/null +++ b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/vector/XmlVectorParser.kt @@ -0,0 +1,276 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.jetbrains.compose.resources.vector + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.vector.DefaultPivotX +import androidx.compose.ui.graphics.vector.DefaultPivotY +import androidx.compose.ui.graphics.vector.DefaultRotation +import androidx.compose.ui.graphics.vector.DefaultScaleX +import androidx.compose.ui.graphics.vector.DefaultScaleY +import androidx.compose.ui.graphics.vector.DefaultTranslationX +import androidx.compose.ui.graphics.vector.DefaultTranslationY +import androidx.compose.ui.graphics.vector.EmptyPath +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import org.jetbrains.compose.resources.vector.BuildContext.Group +import androidx.compose.ui.unit.Density +import org.jetbrains.compose.resources.vector.xmldom.* + + +// Parsing logic is the same as in Android implementation +// (compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/graphics/vector/compat/XmlVectorParser.kt) +// +// Except there is no support for linking with external resources +// (for example, we can't reference to color defined in another file) +// +// Specification: +// https://developer.android.com/reference/android/graphics/drawable/VectorDrawable + +private const val ANDROID_NS = "http://schemas.android.com/apk/res/android" +private const val AAPT_NS = "http://schemas.android.com/aapt" + +private class BuildContext { + val currentGroups = mutableListOf() + + enum class Group { + /** + * Group that exists in xml file + */ + Real, + + /** + * Group that doesn't exist in xml file. We add it manually when we see node. + * It will be automatically popped when the real group will be popped. + */ + Virtual + } +} + +fun Element.parseVectorRoot(density: Density): ImageVector { + val context = BuildContext() + val builder = ImageVector.Builder( + defaultWidth = attributeOrNull(ANDROID_NS, "width").parseDp(density), + defaultHeight = attributeOrNull(ANDROID_NS, "height").parseDp(density), + viewportWidth = attributeOrNull(ANDROID_NS, "viewportWidth")?.toFloat() ?: 0f, + viewportHeight = attributeOrNull(ANDROID_NS, "viewportHeight")?.toFloat() ?: 0f + ) + parseVectorNodes(builder, context) + return builder.build() +} + +private fun Element.parseVectorNodes(builder: ImageVector.Builder, context: BuildContext) { + childrenSequence + .filterIsInstance() + .forEach { + it.parseVectorNode(builder, context) + } +} + +private fun Element.parseVectorNode(builder: ImageVector.Builder, context: BuildContext) { + when (nodeName) { + "path" -> parsePath(builder) + "clip-path" -> parseClipPath(builder, context) + "group" -> parseGroup(builder, context) + } +} + +private fun Element.parsePath(builder: ImageVector.Builder) { + builder.addPath( + pathData = addPathNodes(attributeOrNull(ANDROID_NS, "pathData")), + pathFillType = attributeOrNull(ANDROID_NS, "fillType") + ?.let(::parseFillType) ?: PathFillType.NonZero, + name = attributeOrNull(ANDROID_NS, "name") ?: "", + fill = attributeOrNull(ANDROID_NS, "fillColor")?.let(::parseStringBrush) + ?: apptAttr(ANDROID_NS, "fillColor")?.let(Element::parseElementBrush), + fillAlpha = attributeOrNull(ANDROID_NS, "fillAlpha")?.toFloat() ?: 1.0f, + stroke = attributeOrNull(ANDROID_NS, "strokeColor")?.let(::parseStringBrush) + ?: apptAttr(ANDROID_NS, "strokeColor")?.let(Element::parseElementBrush), + strokeAlpha = attributeOrNull(ANDROID_NS, "strokeAlpha")?.toFloat() ?: 1.0f, + strokeLineWidth = attributeOrNull(ANDROID_NS, "strokeWidth")?.toFloat() ?: 1.0f, + strokeLineCap = attributeOrNull(ANDROID_NS, "strokeLineCap") + ?.let(::parseStrokeCap) ?: StrokeCap.Butt, + strokeLineJoin = attributeOrNull(ANDROID_NS, "strokeLineJoin") + ?.let(::parseStrokeJoin) ?: StrokeJoin.Miter, + strokeLineMiter = attributeOrNull(ANDROID_NS, "strokeMiterLimit")?.toFloat() ?: 1.0f, + trimPathStart = attributeOrNull(ANDROID_NS, "trimPathStart")?.toFloat() ?: 0.0f, + trimPathEnd = attributeOrNull(ANDROID_NS, "trimPathEnd")?.toFloat() ?: 1.0f, + trimPathOffset = attributeOrNull(ANDROID_NS, "trimPathOffset")?.toFloat() ?: 0.0f + ) +} + +private fun Element.parseClipPath(builder: ImageVector.Builder, context: BuildContext) { + builder.addGroup( + name = attributeOrNull(ANDROID_NS, "name") ?: "", + clipPathData = addPathNodes(attributeOrNull(ANDROID_NS, "pathData")) + ) + context.currentGroups.add(Group.Virtual) +} + +private fun Element.parseGroup(builder: ImageVector.Builder, context: BuildContext) { + builder.addGroup( + attributeOrNull(ANDROID_NS, "name") ?: "", + attributeOrNull(ANDROID_NS, "rotation")?.toFloat() ?: DefaultRotation, + attributeOrNull(ANDROID_NS, "pivotX")?.toFloat() ?: DefaultPivotX, + attributeOrNull(ANDROID_NS, "pivotY")?.toFloat() ?: DefaultPivotY, + attributeOrNull(ANDROID_NS, "scaleX")?.toFloat() ?: DefaultScaleX, + attributeOrNull(ANDROID_NS, "scaleY")?.toFloat() ?: DefaultScaleY, + attributeOrNull(ANDROID_NS, "translateX")?.toFloat() ?: DefaultTranslationX, + attributeOrNull(ANDROID_NS, "translateY")?.toFloat() ?: DefaultTranslationY, + EmptyPath + ) + context.currentGroups.add(Group.Real) + + parseVectorNodes(builder, context) + + do { + val removedGroup = context.currentGroups.removeLastOrNull() + builder.clearGroup() + } while (removedGroup == Group.Virtual) +} + +private fun parseStringBrush(str: String) = SolidColor(Color(parseColorValue(str))) + +private fun Element.parseElementBrush(): Brush? = + childrenSequence + .filterIsInstance() + .find { it.nodeName == "gradient" } + ?.parseGradient() + +private fun Element.parseGradient(): Brush? { + return when (attributeOrNull(ANDROID_NS, "type")) { + "linear" -> parseLinearGradient() + "radial" -> parseRadialGradient() + "sweep" -> parseSweepGradient() + else -> null + } +} + +@Suppress("CHANGING_ARGUMENTS_EXECUTION_ORDER_FOR_NAMED_VARARGS") +private fun Element.parseLinearGradient() = Brush.linearGradient( + colorStops = parseColorStops(), + start = Offset( + attributeOrNull(ANDROID_NS, "startX")?.toFloat() ?: 0f, + attributeOrNull(ANDROID_NS, "startY")?.toFloat() ?: 0f + ), + end = Offset( + attributeOrNull(ANDROID_NS, "endX")?.toFloat() ?: 0f, + attributeOrNull(ANDROID_NS, "endY")?.toFloat() ?: 0f + ), + tileMode = attributeOrNull(ANDROID_NS, "tileMode")?.let(::parseTileMode) ?: TileMode.Clamp +) + +@Suppress("CHANGING_ARGUMENTS_EXECUTION_ORDER_FOR_NAMED_VARARGS") +private fun Element.parseRadialGradient() = Brush.radialGradient( + colorStops = parseColorStops(), + center = Offset( + attributeOrNull(ANDROID_NS, "centerX")?.toFloat() ?: 0f, + attributeOrNull(ANDROID_NS, "centerY")?.toFloat() ?: 0f + ), + radius = attributeOrNull(ANDROID_NS, "gradientRadius")?.toFloat() ?: 0f, + tileMode = attributeOrNull(ANDROID_NS, "tileMode")?.let(::parseTileMode) ?: TileMode.Clamp +) + +@Suppress("CHANGING_ARGUMENTS_EXECUTION_ORDER_FOR_NAMED_VARARGS") +private fun Element.parseSweepGradient() = Brush.sweepGradient( + colorStops = parseColorStops(), + center = Offset( + attributeOrNull(ANDROID_NS, "centerX")?.toFloat() ?: 0f, + attributeOrNull(ANDROID_NS, "centerY")?.toFloat() ?: 0f, + ) +) + +private fun Element.parseColorStops(): Array> { + val items = childrenSequence + .filterIsInstance() + .filter { it.nodeName == "item" } + .toList() + + val colorStops = items.mapIndexedNotNullTo(mutableListOf()) { index, item -> + item.parseColorStop(defaultOffset = index.toFloat() / items.lastIndex.coerceAtLeast(1)) + } + + if (colorStops.isEmpty()) { + val startColor = attributeOrNull(ANDROID_NS, "startColor")?.let(::parseColorValue) + val centerColor = attributeOrNull(ANDROID_NS, "centerColor")?.let(::parseColorValue) + val endColor = attributeOrNull(ANDROID_NS, "endColor")?.let(::parseColorValue) + + if (startColor != null) { + colorStops.add(0f to Color(startColor)) + } + if (centerColor != null) { + colorStops.add(0.5f to Color(centerColor)) + } + if (endColor != null) { + colorStops.add(1f to Color(endColor)) + } + } + + return colorStops.toTypedArray() +} + +private fun Element.parseColorStop(defaultOffset: Float): Pair? { + val offset = attributeOrNull(ANDROID_NS, "offset")?.toFloat() ?: defaultOffset + val color = attributeOrNull(ANDROID_NS, "color")?.let(::parseColorValue) ?: return null + return offset to Color(color) +} + +private fun Element.attributeOrNull(namespace: String, name: String): String? { + val value = getAttributeNS(namespace, name) + return if (value.isNotBlank()) value else null +} + +/** + * Attribute of an element can be represented as a separate child: + * + * + * + * + * + * + * + * instead of: + * + * + */ +private fun Element.apptAttr( + namespace: String, + name: String +): Element? { + val prefix = lookupPrefix(namespace) + return childrenSequence + .filterIsInstance() + .find { + it.namespaceURI == AAPT_NS && it.localName == "attr" && + it.getAttribute("name") == "$prefix:$name" + } +} + +private val Element.childrenSequence get() = sequence { + for (i in 0 until childNodes.length) { + yield(childNodes.item(i)) + } +} diff --git a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/vector/xmldom/Element.kt b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/vector/xmldom/Element.kt new file mode 100644 index 0000000000..1bd29a91c6 --- /dev/null +++ b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/vector/xmldom/Element.kt @@ -0,0 +1,11 @@ +package org.jetbrains.compose.resources.vector.xmldom + +/** + * XML DOM Element. + */ +interface Element: Node { + + fun getAttributeNS(nameSpaceURI: String, localName: String): String + + fun getAttribute(name: String): String +} \ No newline at end of file diff --git a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/vector/xmldom/Node.kt b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/vector/xmldom/Node.kt new file mode 100644 index 0000000000..88e0205cab --- /dev/null +++ b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/vector/xmldom/Node.kt @@ -0,0 +1,14 @@ +package org.jetbrains.compose.resources.vector.xmldom + +/** + * XML DOM Node. + */ +interface Node { + val nodeName: String + val localName: String + + val childNodes: NodeList + val namespaceURI: String + + fun lookupPrefix(namespaceURI: String): String +} \ No newline at end of file diff --git a/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/vector/xmldom/NodeList.kt b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/vector/xmldom/NodeList.kt new file mode 100644 index 0000000000..3883fad0ab --- /dev/null +++ b/components/resources/library/src/commonMain/kotlin/org/jetbrains/compose/resources/vector/xmldom/NodeList.kt @@ -0,0 +1,10 @@ +package org.jetbrains.compose.resources.vector.xmldom + +/** + * XML DOM Node list. + */ +interface NodeList { + fun item(i: Int): Node + + val length: Int +} \ No newline at end of file diff --git a/components/resources/library/src/iosMain/kotlin/org/jetbrains/compose/resources/Resource.ios.kt b/components/resources/library/src/iosMain/kotlin/org/jetbrains/compose/resources/Resource.ios.kt index c081555e1b..6e39c00bc0 100644 --- a/components/resources/library/src/iosMain/kotlin/org/jetbrains/compose/resources/Resource.ios.kt +++ b/components/resources/library/src/iosMain/kotlin/org/jetbrains/compose/resources/Resource.ios.kt @@ -5,9 +5,10 @@ package org.jetbrains.compose.resources - +import org.jetbrains.compose.resources.vector.xmldom.Element import kotlinx.cinterop.addressOf import kotlinx.cinterop.usePinned +import org.jetbrains.compose.resources.vector.xmldom.parse import platform.Foundation.NSBundle import platform.Foundation.NSData import platform.Foundation.NSFileManager @@ -35,3 +36,5 @@ private class UIKitResourceImpl(path: String) : AbstractResourceImpl(path) { internal actual class MissingResourceException actual constructor(path: String) : Exception("Missing resource with path: $path") + +internal actual fun parseXML(byteArray: ByteArray): Element = parse(byteArray.decodeToString()) \ No newline at end of file diff --git a/components/resources/library/src/iosMain/kotlin/org/jetbrains/compose/resources/xmldom/DomXmlParser.kt b/components/resources/library/src/iosMain/kotlin/org/jetbrains/compose/resources/xmldom/DomXmlParser.kt new file mode 100644 index 0000000000..1aff9bfdde --- /dev/null +++ b/components/resources/library/src/iosMain/kotlin/org/jetbrains/compose/resources/xmldom/DomXmlParser.kt @@ -0,0 +1,114 @@ +/* + * Copyright 2020-2021 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ +package org.jetbrains.compose.resources.vector.xmldom + +import platform.Foundation.* +import platform.darwin.NSObject + +fun parse(xml: String): Element { + val parser = DomXmlParser() + NSXMLParser((xml as NSString).dataUsingEncoding(NSUTF8StringEncoding)!!).apply { + shouldReportNamespacePrefixes = true + shouldProcessNamespaces = true + delegate = parser + }.parse() + return parser.root!! +} + +class MalformedXMLException(message: String?) : Exception(message) + +private class ElementImpl(override val localName: String, + override val nodeName: String, + override val namespaceURI: String, + val prefixMap: Map, + val attributes: Map): Element { + + override val childNodes: NodeList + get() = object : NodeList { + override fun item(i: Int): Node { + return childs[i] + } + + override val length: Int + get() = childs.size + } + + + var childs = mutableListOf() + + override fun getAttributeNS(nameSpaceURI: String, localName: String): String { + val prefix = prefixMap[nameSpaceURI] + val attrKey = if (prefix == null) localName else "$prefix:$localName" + return getAttribute(attrKey) + } + + override fun getAttribute(name: String): String = attributes[name] as String? ?:"" + + override fun lookupPrefix(uri: String): String = prefixMap[uri]?:"" +} + +@Suppress("CONFLICTING_OVERLOADS") +private class DomXmlParser( + +) : NSObject(), NSXMLParserDelegateProtocol { + + val curPrefixMapInverted = mutableMapOf() + + var curPrefixMap: Map = emptyMap() + + val nodeStack = mutableListOf() + + var root: Element? = null + + override fun parser( + parser: NSXMLParser, + didStartElement: String, + namespaceURI: String?, + qualifiedName: String?, + attributes: Map + ) { + val node = ElementImpl(didStartElement, qualifiedName!!, namespaceURI?:"", + curPrefixMap, attributes) + + if (root == null) root = node + + if (!nodeStack.isEmpty()) + nodeStack.last().childs.add(node) + + nodeStack.add(node) + } + + override fun parser( + parser: NSXMLParser, + didEndElement: String, + namespaceURI: String?, + qualifiedName: String? + ) { + val node = nodeStack.removeLast() + assert(node.localName.equals(didEndElement)) + } + + override fun parser(parser: NSXMLParser, + didStartMappingPrefix: String, + toURI: String + ) { + curPrefixMapInverted.put(didStartMappingPrefix, toURI) + curPrefixMap = curPrefixMapInverted.entries.associateBy({ it.value }, { it.key }) + } + + override fun parser(parser: NSXMLParser, didEndMappingPrefix: String) { + curPrefixMapInverted.remove(didEndMappingPrefix) + curPrefixMap = curPrefixMapInverted.entries.associateBy({ it.value }, { it.key }) + } + + override fun parser(parser: NSXMLParser, validationErrorOccurred: NSError) { + throw MalformedXMLException("validation error occurred") + } + + override fun parser(parser: NSXMLParser, parseErrorOccurred: NSError) { + throw MalformedXMLException("parse error occurred") + } +} + diff --git a/components/resources/library/src/jsMain/kotlin/org/jetbrains/compose/resources/Resource.js.kt b/components/resources/library/src/jsMain/kotlin/org/jetbrains/compose/resources/Resource.js.kt index e0a78a5193..b05f8a49ad 100644 --- a/components/resources/library/src/jsMain/kotlin/org/jetbrains/compose/resources/Resource.js.kt +++ b/components/resources/library/src/jsMain/kotlin/org/jetbrains/compose/resources/Resource.js.kt @@ -5,6 +5,7 @@ package org.jetbrains.compose.resources +import org.jetbrains.compose.resources.vector.xmldom.Element import org.khronos.webgl.ArrayBuffer import org.khronos.webgl.Int8Array import org.w3c.xhr.ARRAYBUFFER @@ -42,3 +43,7 @@ private fun ArrayBuffer.toByteArray() = Int8Array(this, 0, byteLength).unsafeCas internal actual class MissingResourceException actual constructor(path: String) : Exception("Missing resource with path: $path") + +internal actual fun parseXML(byteArray: ByteArray): Element { + throw UnsupportedOperationException("XML Vector Drawables are not supported for Web target") +} \ No newline at end of file diff --git a/components/resources/library/src/jvmAndAndroidMain/kotlin/org/jetbrains/compose/resources/Resource.jvmandandroid.kt b/components/resources/library/src/jvmAndAndroidMain/kotlin/org/jetbrains/compose/resources/Resource.jvmandandroid.kt new file mode 100644 index 0000000000..f4866cc5a8 --- /dev/null +++ b/components/resources/library/src/jvmAndAndroidMain/kotlin/org/jetbrains/compose/resources/Resource.jvmandandroid.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2020-2022 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package org.jetbrains.compose.resources + +import org.jetbrains.compose.resources.vector.xmldom.Element +import org.jetbrains.compose.resources.vector.xmldom.ElementImpl +import org.xml.sax.InputSource +import java.io.ByteArrayInputStream +import javax.xml.parsers.DocumentBuilderFactory + +internal actual fun parseXML(byteArray: ByteArray): Element = + ElementImpl( + DocumentBuilderFactory + .newInstance().apply { + isNamespaceAware = true + } + .newDocumentBuilder() + .parse(InputSource(ByteArrayInputStream(byteArray))) + .documentElement) diff --git a/components/resources/library/src/jvmAndAndroidMain/kotlin/org/jetbrains/compose/resources/xmldom/ElementImpl.kt b/components/resources/library/src/jvmAndAndroidMain/kotlin/org/jetbrains/compose/resources/xmldom/ElementImpl.kt new file mode 100644 index 0000000000..a72eda6887 --- /dev/null +++ b/components/resources/library/src/jvmAndAndroidMain/kotlin/org/jetbrains/compose/resources/xmldom/ElementImpl.kt @@ -0,0 +1,10 @@ +package org.jetbrains.compose.resources.vector.xmldom + +import org.w3c.dom.Element as DomElement + +class ElementImpl(val element: DomElement): NodeImpl(element), Element { + override fun getAttributeNS(nameSpaceURI: String, localName: String): String = + element.getAttributeNS(nameSpaceURI, localName) + + override fun getAttribute(name: String): String = element.getAttribute(name) +} \ No newline at end of file diff --git a/components/resources/library/src/jvmAndAndroidMain/kotlin/org/jetbrains/compose/resources/xmldom/NodeImpl.kt b/components/resources/library/src/jvmAndAndroidMain/kotlin/org/jetbrains/compose/resources/xmldom/NodeImpl.kt new file mode 100644 index 0000000000..fbafd4d499 --- /dev/null +++ b/components/resources/library/src/jvmAndAndroidMain/kotlin/org/jetbrains/compose/resources/xmldom/NodeImpl.kt @@ -0,0 +1,28 @@ +package org.jetbrains.compose.resources.vector.xmldom + +import org.w3c.dom.Node as DomNode +import org.w3c.dom.Element as DomElement + +open class NodeImpl(val n: DomNode): Node { + override val nodeName: String + get() = n.nodeName + override val localName: String + get() = n.localName + override val childNodes: NodeList + get() = + object: NodeList { + override fun item(i: Int): Node { + val child = n.childNodes.item(i) + return if (child is DomElement) ElementImpl(child) else NodeImpl(child) + } + + override val length: Int = n.childNodes.length + + } + + override val namespaceURI: String + get() = n.namespaceURI + + override fun lookupPrefix(namespaceURI: String): String = n.lookupPrefix(namespaceURI) + +} \ No newline at end of file diff --git a/components/resources/library/src/macosMain/kotlin/org/jetbrains/compose/resources/Resource.macos.kt b/components/resources/library/src/macosMain/kotlin/org/jetbrains/compose/resources/Resource.macos.kt index fedc16c725..c1dcf6ad30 100644 --- a/components/resources/library/src/macosMain/kotlin/org/jetbrains/compose/resources/Resource.macos.kt +++ b/components/resources/library/src/macosMain/kotlin/org/jetbrains/compose/resources/Resource.macos.kt @@ -5,6 +5,7 @@ package org.jetbrains.compose.resources +import org.jetbrains.compose.resources.vector.xmldom.Element import kotlinx.cinterop.addressOf import kotlinx.cinterop.usePinned import platform.Foundation.NSData @@ -37,3 +38,7 @@ private class MacOSResourceImpl(path: String) : AbstractResourceImpl(path) { internal actual class MissingResourceException actual constructor(path: String) : Exception("Missing resource with path: $path") + +internal actual fun parseXML(byteArray: ByteArray): Element { + throw UnsupportedOperationException("XML Vector Drawables are not supported for MacOS target") +}