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