1 /* 2 * Copyright 2024 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package androidx.graphics.shapes 18 19 import kotlin.jvm.JvmStatic 20 21 /** 22 * The [FeatureSerializer] is used to both serialize and parse [Feature] objects. This is beneficial 23 * when you want to re-use [RoundedPolygon] objects created by [SvgPathParser], as parsing 24 * serialized [Feature] objects is more performant than using the svg path import. 25 * 26 * **Example:** 27 * 28 * ``` 29 * // Do the following three *once* 30 * val triangleSVGPath: String = "M0,0 0.5,1 1,0Z" 31 * val triangleFeatures: List<Feature> = SvgPathParser.parseFeatures(triangleSVGPath) 32 * val serializedTriangle: String = FeatureSerializer.serialize(triangleFeatures) 33 * 34 * // Parse the serialized triangle features in your production code. 35 * // You can adjust them (e.g. the type) however you want before parsing. 36 * val features: List<Feature> = FeatureSerializer.parse(serializedTriangle) 37 * val triangle: RoundedPolygon = RoundedPolygon(features, centerX = 0.5f, centerY = 0.5f) 38 * Morph(triangle, ...) 39 * ``` 40 */ 41 class FeatureSerializer private constructor() { 42 companion object { 43 44 /** 45 * Serializes a list of [Feature] objects into a string representation, adhering to version 46 * 1 of the feature serialization format. 47 * 48 * **Format:** 49 * 1. **Version Identifier:** A 'V' followed by the version number (e.g., 'V1'). 50 * 2. **Feature Serialization:** Each [Feature] is serialized individually and concatenated. 51 * - **Feature Tag:** A single-character tag identifies the feature type: 52 * - 'n': Edge 53 * - 'x': Convex Corner 54 * - 'o': Concave Corner 55 * - **Cubic Serialization:** Each [Cubic] within a feature is serialized as a sequence 56 * of comma-separated x,y coordinates. Subsequent cubics within a feature omit their 57 * first anchor points, as they're identical to their predecessors' last anchor 58 * points. 59 * 60 * **Example:** Given two features: 61 * - An edge with one [Cubic]: { (0, 0), (1, 1), (2, 2), (3, 3) } 62 * - A convex corner with two [Cubic] objects: { (0, 0), (1, 1), (2, 2), (3, 3) }, { (3, 3), 63 * (4, 4), (5, 5), (6, 6) } 64 * 65 * The serialized string would be: 66 * ``` 67 * V1 n 0,0, 1,1, 2,2, 3,3 x 0,0, 1,1, 2,2, 3,3, 4,4, 5,5, 6,6 68 * ``` 69 * 70 * @param features The list of [Feature] objects to serialize. 71 * @return The serialized string representation of the [features] 72 */ 73 @JvmStatic serializenull74 fun serialize(features: List<Feature>): String { 75 return buildString { 76 append("V1") 77 for (feature in features) { 78 append(serializeFeature(feature)) 79 } 80 } 81 } 82 83 /** 84 * Parses a serialized string representation of [Feature] into their object representations, 85 * adhering to version 1 of the feature serialization format. 86 * 87 * The serialized string must adhere to the format generated by 88 * [FeatureSerializer.serialize]. This format consists of: 89 * 1. **Version Identifier:** A 'V' followed by the version number (e.g., 'V1'). 90 * 2. **Feature Serialization:** Each [Feature] is serialized individually and concatenated. 91 * - **Feature Tag:** A single-character tag identifies the feature type: 92 * - 'n': Edge 93 * - 'x': Convex Corner 94 * - 'o': Concave Corner 95 * - Any other tags unknown to version 1 will default to an edge interpretation. 96 * - **Cubic Serialization:** Each [Cubic] within a feature is serialized as a sequence 97 * of comma-separated x,y coordinates. Subsequent cubics within a feature omit their 98 * first anchor points, as they're identical to their predecessors' last anchor 99 * points. 100 * 101 * **Note:** The current version (1) of the serialization format is stable. However, future 102 * versions may introduce incompatible changes with version 1. The default behavior for 103 * unknown or missing versions will be version 1 parsing. If you have a later version 104 * string, update the library to the latest version. 105 * 106 * @param serializedFeatures The serialized [Feature] objects in a concatenated String. 107 * @return A list of parsed [Feature] objects, corresponding to the serialized input. 108 * @throws IllegalArgumentException - if a serialized string lacks sufficient points to 109 * create [Cubic] objects with 110 * @throws IllegalArgumentException - if no feature tags could be found 111 * @throws NumberFormatException - if the [Cubic]s' coordinates aren't valid representations 112 * of numbers. 113 */ 114 @JvmStatic parsenull115 fun parse(serializedFeatures: String): List<Feature> { 116 val version = Regex("^\\s*V(\\d+)").find(serializedFeatures) 117 var tagsSearchStart = 0 118 119 if (version == null || version.groupValues.size < 2) { 120 debugLog(LOG_TAG) { 121 "Could not find any version attached to the start of the input. Will default to V1 parsing." 122 } 123 } else { 124 if (version.groupValues[1] != "1") { 125 debugLog(LOG_TAG) { 126 "Found an unsupported version number ${version.groupValues[1]}. Will default to version 1 parsing. Please update your library version to the latest version to fix this issue. " 127 } 128 } 129 tagsSearchStart = version.value.length 130 } 131 132 val tags = Regex("[a-zA-Z]").find(serializedFeatures, tagsSearchStart) 133 require(tags != null) { 134 "Could not find any feature tags. Please mark all cubic bezier curve points belonging to a feature with one of {${FEATURE_TAG_ARRAY.joinToString(", ")}} for V1, e.g. 'n1,1,2,2,3,3,4,4' for an edge (n) with anchor 0 (1,1), control 0 (2,2), control 1 (3,3) and anchor 1 (4,4)." 135 } 136 137 var currentMatch = tags 138 var featureStart: Int 139 var featureEnd: Int 140 return buildList { 141 while (currentMatch != null) { 142 featureStart = currentMatch!!.range.first 143 currentMatch = currentMatch!!.next() 144 featureEnd = 145 if (currentMatch != null) currentMatch!!.range.first 146 else serializedFeatures.length 147 add(parseFeature(serializedFeatures, featureStart, featureEnd)) 148 } 149 } 150 } 151 serializeFeaturenull152 private fun serializeFeature(feature: Feature): String = 153 when (feature) { 154 is Feature.Edge -> { 155 EDGE_CHAR + serializeCubics(feature.cubics) 156 } 157 is Feature.Corner -> { 158 val convexFlag = if (feature.convex) CONVEX_CORNER_CHAR else CONCAVE_CORNER_CHAR 159 convexFlag + serializeCubics(feature.cubics) 160 } 161 else -> { 162 debugLog(LOG_TAG) { 163 "Serializing a Feature unknown to V1 (knows Edge and Corner). Will default to parse as an edge. Please update the library to the latest version to fix this issue." 164 } 165 EDGE_CHAR + serializeCubics(feature.cubics) 166 } 167 } 168 serializeCubicsnull169 private fun serializeCubics(cubics: List<Cubic>): String { 170 // since cubics in a polygon are continuous, we don't need to include the end 171 // coordinates as they are the same as the start coordinates of their successors. 172 // this is similar to svg path commands. 173 val separatorString = SEPARATOR.toString() 174 return buildString { 175 for (cubic in cubics) { 176 append( 177 cubic.points.joinToString( 178 separator = separatorString, 179 limit = 6, 180 truncated = "" 181 ) { 182 it.toString().removeTrailingZeroes() 183 } 184 ) 185 } 186 val lastX = cubics.last().anchor1X.toString().removeTrailingZeroes() 187 val lastY = cubics.last().anchor1Y.toString().removeTrailingZeroes() 188 append("${lastX}$SEPARATOR${lastY}") 189 } 190 } 191 parseFeaturenull192 private fun parseFeature(serialized: String, startIndex: Int, endIndex: Int): Feature { 193 return when (serialized[startIndex]) { 194 EDGE_CHAR -> Feature.Edge(parseCubics(serialized, startIndex + 1, endIndex)) 195 CONVEX_CORNER_CHAR -> 196 Feature.Corner(parseCubics(serialized, startIndex + 1, endIndex), true) 197 CONCAVE_CORNER_CHAR -> 198 Feature.Corner(parseCubics(serialized, startIndex + 1, endIndex), false) 199 else -> { 200 debugLog(LOG_TAG) { 201 "Found an unknown Feature tag for V1 parsing. Given: ${serialized[startIndex]}, supported: {${FEATURE_TAG_ARRAY.joinToString(", ")}}. Will default to Edge. Use a V1 supported tag or update the library to the latest version to fix this issue." 202 } 203 Feature.Edge(parseCubics(serialized, startIndex + 1, endIndex)) 204 } 205 } 206 } 207 parseCubicsnull208 private fun parseCubics(serialized: String, startIndex: Int, endIndex: Int): List<Cubic> { 209 // this is a very low level implementation to avoid allocations as the parsing of 210 // serialized shapes can happen per frame. the functional equivalent would be 211 // val points = serialized.serialized.substring(startIndex, endIndex) 212 // .split(separator).map { it.toFloat() } 213 // return points.windowed(8, step = 6).map { Cubic(it.toFloatArray()) } 214 215 val windowSize = 8 216 val windowStep = 6 217 218 var pointStart = startIndex 219 var pointEnd = startIndex 220 var pointCount = 0 221 var points = FloatArray(windowSize) 222 223 // helper values saved in top level to avoid new allocations 224 var nextStartX: Float 225 var nextStartY: Float 226 227 return buildList { 228 while (pointEnd < endIndex) { 229 // the number is still going, move to next character 230 if (serialized[pointEnd] != SEPARATOR) { 231 pointEnd++ 232 continue 233 } 234 235 // a number ended, add it to the points array and increase count 236 points[pointCount++] = serialized.substring(pointStart, pointEnd).toFloat() 237 pointStart = pointEnd + 1 238 239 // if we closed our window, parse it to cubic and reset values for the 240 // next window 241 if (pointCount == windowSize) { 242 add(Cubic(points)) 243 244 // end of this cubic is start of next. we reset the points array 245 // because cubics save references to them and we would otherwise 246 // need to deep copy the array to avoid the same reference across all cubics 247 nextStartX = points[windowSize - 2] 248 nextStartY = points[windowSize - 1] 249 points = FloatArray(windowSize) 250 points[0] = nextStartX 251 points[1] = nextStartY 252 pointCount -= windowStep 253 } 254 255 pointEnd++ 256 } 257 258 require(pointCount + 1 == windowSize) { 259 val neededPoints = 260 try { 261 serialized.substring(pointStart, pointEnd).toFloat() 262 windowSize - (pointCount + 1) 263 } catch (exception: NumberFormatException) { 264 windowSize - pointCount 265 } 266 267 "Received a feature with an insufficient amount of numbers for substring '${serialized.substring(startIndex-1, endIndex)}'. Wanted to create ${this.size+1} continuous cubic bezier curves for this feature, but the last one is missing $neededPoints more numbers separated by '$SEPARATOR'." 268 } 269 270 // add last point and last cubic 271 points[windowSize - 1] = serialized.substring(pointStart, pointEnd).toFloat() 272 add(Cubic(points)) 273 } 274 } 275 removeTrailingZeroesnull276 private fun String.removeTrailingZeroes(): String = 277 this.trimEnd { it == '0' }.trimEnd { it == '.' } 278 279 private const val SEPARATOR = ',' 280 private const val CONVEX_CORNER_CHAR = 'x' 281 private const val CONCAVE_CORNER_CHAR = 'o' 282 private const val EDGE_CHAR = 'n' 283 private val FEATURE_TAG_ARRAY = 284 charArrayOf(EDGE_CHAR, CONVEX_CORNER_CHAR, CONCAVE_CORNER_CHAR) 285 private const val LOG_TAG = "FeatureSerializer" 286 } 287 } 288