1 /*
<lambda>null2  * 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 import kotlin.math.PI
21 import kotlin.math.abs
22 import kotlin.math.atan2
23 import kotlin.math.ceil
24 import kotlin.math.cos
25 import kotlin.math.sin
26 import kotlin.math.sqrt
27 import kotlin.math.tan
28 
29 // TODO: b/370041761 Remove manual parsing and low level operations when multiplatform SVG parser
30 // exists
31 
32 /**
33  * Converts each command (beside move to) of a svg path to a list of [Cubic]s by calling
34  * [SvgPathParser.parseCubics]. Any svg path complying to the specification found at
35  * https://www.w3.org/TR/SVG/paths.html is supported. Parameters can either be split by whitespace
36  * or by commas. There is very little error handling, so use with valid paths and consult the debug
37  * logs for unexpected [Cubic] objects.
38  */
39 class SvgPathParser private constructor(startPosition: Point) {
40 
41     companion object {
42 
43         /**
44          * Converts an SVG path string into a list of [Feature] objects. The polygon described in
45          * the path should be representing a single, closed, non-self-intersecting polygon.
46          * Otherwise, either an error is thrown or the [Morph] that the polygon is used in will be
47          * distorted.
48          *
49          * **Note:**
50          * - Only the first shape within the SVG path is processed. Subsequent shapes or holes are
51          *   ignored.
52          * - This method is primarily intended for development and testing purposes. For production
53          *   use, serialize and parse the [Feature] objects with [FeatureSerializer].
54          *
55          * **Usage:**
56          *
57          *  ```
58          *  // Do the following three *once*
59          *  val triangleSVGPath: String = "M0,0 0.5,1 1,0Z"
60          *  val triangleFeatures: List<Feature> = SvgPathParser.parseFeatures(triangleSVGPath)
61          *  val serializedTriangle: String = FeatureSerializer.serialize(triangleFeatures)
62          *
63          *  // Parse the serialized triangle features in your production code.
64          *  // You can adjust them (e.g. the type) however you want before parsing.
65          *  val features: List<Feature> = FeatureSerializer.parse(serializedTriangle)
66          *  val triangle: RoundedPolygon = RoundedPolygon(features, centerX = 0.5f, centerY = 0.5f)
67          *  Morph(triangle, ...)
68          *  ```
69          *
70          * @param svgPath The SVG path string, typically extracted from the `d` attribute of an SVG
71          *   element.
72          * @return A list of [Feature] objects that can be used to create [RoundedPolygon]s
73          * @throws IllegalArgumentException - if the SVG path is invalid or represents a non-closed
74          *   or self-intersecting polygon.
75          */
76         @JvmStatic
77         fun parseFeatures(svgPath: String): List<Feature> {
78             val parsedCubics = parseCubics(svgPath)
79 
80             val continuous = { first: Cubic, second: Cubic ->
81                 abs(second.anchor0X - first.anchor1X) < DistanceEpsilon &&
82                     abs(second.anchor0Y - first.anchor1Y) < DistanceEpsilon
83             }
84 
85             // TODO: b/376039669 add parameter to choose shape
86             var continuousCubicsCount = parsedCubics.size
87             for (index in 0 until parsedCubics.lastIndex) {
88                 val current = parsedCubics[index]
89                 val next = parsedCubics[index + 1]
90                 if (!continuous(current, next)) {
91                     continuousCubicsCount = index
92                     break
93                 }
94             }
95             val firstShapeCubics = parsedCubics.take(continuousCubicsCount)
96 
97             val parsedPolygon = RoundedPolygon(detectFeatures(firstShapeCubics))
98             val fixedPolygon = PolygonValidator.fix(parsedPolygon)
99 
100             return fixedPolygon.features
101         }
102 
103         /**
104          * Converts the path elements of svgPath to their cubic counterparts. svgPath corresponds to
105          * the data found in the path's "d" property.
106          */
107         internal fun parseCubics(svgPath: String): List<Cubic> {
108             val paths = svgPath.split(Regex("(?=[mM])")).filter { it.isNotBlank() }
109             var current = Point(0f, 0f)
110 
111             // The input may contain multiple move to commands, in which later ones can be
112             // relative. Therefore we need to finish one path before we parse the following,
113             // so we have the correct start positions
114             return buildList {
115                 paths.forEach { path ->
116                     val commandStrings =
117                         path.split(Regex("(?=[a-zA-Z])")).filter { it.isNotBlank() }
118 
119                     // Paths start with move commands that define the starting position
120                     // Subsequent pairs are equal to line commands
121                     val moveToCommand = Command.parse(commandStrings.first(), current)
122                     current = moveToCommand.start + Point(moveToCommand[0], moveToCommand[1])
123 
124                     val parser = SvgPathParser(current)
125 
126                     // Move to command already handled, handle subsequent line commands (if any)
127                     parser.parseCommand(moveToCommand.asLine(current))
128                     commandStrings.drop(1).forEach {
129                         parser.parseCommand(Command.parse(it, parser.position))
130                     }
131 
132                     addAll(parser.cubics)
133                 }
134             }
135         }
136     }
137 
138     private val cubics: MutableList<Cubic> = mutableListOf()
139 
140     private val start: Point = startPosition
141 
142     private val position: Point
143         get() = cubics.lastOrNull()?.let { Point(it.anchor1X, it.anchor1Y) } ?: start
144 
145     private var previousCommand: Command = Command('I', false, floatArrayOf(), 0)
146 
147     private val reflectedPreviousControlPoint: Point
148         get() = position + (position - Point(cubics.last().control1X, cubics.last().control1Y))
149 
150     private fun parseCommand(command: Command) {
151         if (command.isCloseCommand) {
152             cubics.add(lineToCubic(position, start))
153             return
154         }
155 
156         // A single SVG command can contain multiple parameter pairs. Split them into atomics.
157         for (i in 0..command.parameters.lastIndex step command.paramsCount) {
158             val atomicCommand = command.chunk(i, position)
159 
160             parseAtomicCommand(atomicCommand)
161         }
162     }
163 
164     private fun parseAtomicCommand(atomicCommand: Command) {
165         when {
166             atomicCommand.isLineCommand -> parseLine(atomicCommand)
167             atomicCommand.isCurveCommand -> parseCurve(atomicCommand)
168             atomicCommand.isArcCommand -> parseArc(atomicCommand)
169             else -> {
170                 debugLog(LOG_TAG) { "Ignoring unknown command: ${atomicCommand.letter}" }
171             }
172         }
173 
174         previousCommand = atomicCommand
175     }
176 
177     private fun parseLine(command: Command) {
178         val addLineTo = { endPoint: Point -> cubics.add(lineToCubic(position, endPoint)) }
179 
180         when (command.letter) {
181             'l' -> addLineTo(command.xy(0, 1))
182             'h' -> addLineTo(Point(command.x(0), command.start.y))
183             'v' -> addLineTo(Point(command.start.x, command.y(0)))
184         }
185     }
186 
187     private fun parseCurve(command: Command) {
188         val addCurveWith = { c0: Point, c1: Point, a1: Point ->
189             cubics.add(curveToCubic(position, c0, c1, a1))
190         }
191 
192         when (command.letter) {
193             'c' ->
194                 addCurveWith(
195                     command.xy(0, 1),
196                     command.xy(2, 3),
197                     command.xy(4, 5),
198                 )
199             's' -> {
200                 val c0 =
201                     if (previousCommand.isBezierCommand) reflectedPreviousControlPoint else position
202 
203                 addCurveWith(
204                     c0,
205                     command.xy(0, 1),
206                     command.xy(2, 3),
207                 )
208             }
209             'q' ->
210                 addCurveWith(
211                     command.xy(0, 1),
212                     command.xy(0, 1),
213                     command.xy(2, 3),
214                 )
215             't' -> {
216                 val c0 =
217                     if (previousCommand.isQuadraticCurveCommand) reflectedPreviousControlPoint
218                     else position
219 
220                 addCurveWith(c0, c0, command.xy(0, 1))
221             }
222         }
223     }
224 
225     private fun parseArc(command: Command) {
226         val target = command.xy(5, 6)
227 
228         cubics.addAll(
229             ArcConverter.arcToCubics(
230                 position.x,
231                 position.y,
232                 target.x,
233                 target.y,
234                 command[0],
235                 command[1],
236                 command[2],
237                 command[3] != 0f,
238                 command[4] != 0f,
239             )
240         )
241     }
242 
243     private fun curveToCubic(a0: Point, c0: Point, c1: Point, a1: Point) =
244         Cubic(floatArrayOf(a0.x, a0.y, c0.x, c0.y, c1.x, c1.y, a1.x, a1.y))
245 
246     private fun lineToCubic(start: Point, end: Point) =
247         Cubic.straightLine(start.x, start.y, end.x, end.y)
248 
249     private data class Command(
250         val letter: Char,
251         val isRelative: Boolean,
252         val parameters: FloatArray,
253         val paramsCount: Int,
254         val start: Point = Point(0f, 0f)
255     ) {
256         companion object Factory {
257             private val commandToParamsCount =
258                 mapOf(
259                     'm' to 2,
260                     'l' to 2,
261                     'h' to 1,
262                     'v' to 1,
263                     'c' to 6,
264                     's' to 4,
265                     'q' to 4,
266                     't' to 2,
267                     'a' to 7,
268                 )
269 
270             fun parse(input: String, currentPosition: Point): Command {
271                 val letter = input.first()
272                 val isRelative = letter.isLowerCase()
273                 val parameters =
274                     input
275                         .drop(1)
276                         .split(" ", ",")
277                         .filter { it.isNotBlank() }
278                         .map { it.trim().toFloat() }
279                         .toFloatArray()
280                 return Command(
281                     letter.lowercaseChar(),
282                     isRelative,
283                     parameters,
284                     commandToParamsCount[letter.lowercaseChar()] ?: 0,
285                     if (isRelative) currentPosition else Point(0f, 0f)
286                 )
287             }
288         }
289 
290         val isLineCommand = letter in charArrayOf('l', 'h', 'v')
291         val isBezierCommand = letter in charArrayOf('c', 's')
292         val isQuadraticCurveCommand = letter in charArrayOf('q', 't')
293         val isCurveCommand = letter in charArrayOf('c', 's', 'q', 't')
294         val isArcCommand = letter == 'a'
295         val isCloseCommand = letter == 'z'
296 
297         operator fun get(i: Int): Float = parameters[i]
298 
299         fun x(i: Int): Float {
300             val coordinate = get(i)
301             return if (isRelative) start.x + coordinate else coordinate
302         }
303 
304         fun y(i: Int): Float {
305             val coordinate = get(i)
306             return if (isRelative) start.y + coordinate else coordinate
307         }
308 
309         fun xy(i: Int, j: Int): Point {
310             val coordinates = Point(get(i), get(j))
311             return if (isRelative) start + coordinates else coordinates
312         }
313 
314         fun chunk(index: Int, currentPosition: Point): Command =
315             Command(
316                 letter,
317                 isRelative,
318                 parameters.sliceArray(index until index + paramsCount),
319                 paramsCount,
320                 currentPosition
321             )
322 
323         fun asLine(newStart: Point): Command {
324             val convertedParameters = parameters.drop(paramsCount).toFloatArray()
325             return Command('l', isRelative, convertedParameters, 2, newStart)
326         }
327 
328         // Even though equals and hashCode are not used, they are required by the linter.
329         override fun equals(other: Any?): Boolean {
330             if (this === other) return true
331             if (other == null || this::class != other::class) return false
332 
333             other as Command
334 
335             if (letter != other.letter) return false
336             if (!parameters.contentEquals(other.parameters)) return false
337             if (paramsCount != other.paramsCount) return false
338 
339             return true
340         }
341 
342         override fun hashCode(): Int {
343             var result = letter.hashCode()
344             result = 31 * result + parameters.contentHashCode()
345             result = 31 * result + paramsCount
346             return result
347         }
348     }
349 }
350 
351 private const val LOG_TAG = "SvgPathParser"
352 
353 /**
354  * The following code has been copied and slightly adjusted from graphics/PathParser to remove the
355  * Path dependency.
356  *
357  * TODO: b/370041761 Remove when multiplatform SVG parser with arc support exists
358  */
359 private class ArcConverter {
360     companion object {
arcToCubicsnull361         fun arcToCubics(
362             x0: Float,
363             y0: Float,
364             x1: Float,
365             y1: Float,
366             a: Float,
367             b: Float,
368             theta: Float,
369             isMoreThanHalf: Boolean,
370             isPositiveArc: Boolean
371         ): List<Cubic> {
372             /* Convert rotation angle from degrees to radians */
373             val thetaD: Double = theta.toDouble() / 180 * PI
374             /* Pre-compute rotation matrix entries */
375             val cosTheta = cos(thetaD)
376             val sinTheta = sin(thetaD)
377             /* Transform (x0, y0) and (x1, y1) into unit space */
378             /* using (inverse) rotation, followed by (inverse) scale */
379             val x0p = (x0 * cosTheta + y0 * sinTheta) / a
380             val y0p = (-x0 * sinTheta + y0 * cosTheta) / b
381             val x1p = (x1 * cosTheta + y1 * sinTheta) / a
382             val y1p = (-x1 * sinTheta + y1 * cosTheta) / b
383 
384             /* Compute differences and averages */
385             val dx = x0p - x1p
386             val dy = y0p - y1p
387             val xm = (x0p + x1p) / 2
388             val ym = (y0p + y1p) / 2
389             /* Solve for intersecting unit circles */
390             val dsq = dx * dx + dy * dy
391             if (dsq == 0.0) {
392                 return listOf() /* Points are coincident */
393             }
394             val disc = 1.0 / dsq - 1.0 / 4.0
395             if (disc < 0.0) {
396                 val adjust = (sqrt(dsq) / 1.99999).toFloat()
397                 /* Points are too far apart */
398                 return arcToCubics(
399                     x0,
400                     y0,
401                     x1,
402                     y1,
403                     a * adjust,
404                     b * adjust,
405                     theta,
406                     isMoreThanHalf,
407                     isPositiveArc
408                 )
409             }
410             val s = sqrt(disc)
411             val sdx = s * dx
412             val sdy = s * dy
413             var cx: Double
414             var cy: Double
415             if (isMoreThanHalf == isPositiveArc) {
416                 cx = xm - sdy
417                 cy = ym + sdx
418             } else {
419                 cx = xm + sdy
420                 cy = ym - sdx
421             }
422 
423             val eta0 = atan2((y0p - cy), (x0p - cx))
424 
425             val eta1 = atan2((y1p - cy), (x1p - cx))
426 
427             var sweep = (eta1 - eta0)
428             if (isPositiveArc != (sweep >= 0)) {
429                 if (sweep > 0) {
430                     sweep -= 2 * PI
431                 } else {
432                     sweep += 2 * PI
433                 }
434             }
435 
436             cx *= a.toDouble()
437             cy *= b.toDouble()
438             val tcx = cx
439             cx = cx * cosTheta - cy * sinTheta
440             cy = tcx * sinTheta + cy * cosTheta
441 
442             return arcToBezier(
443                 cx.toFloat(),
444                 cy.toFloat(),
445                 a,
446                 b,
447                 x0,
448                 y0,
449                 thetaD.toFloat(),
450                 eta0.toFloat(),
451                 sweep.toFloat()
452             )
453         }
454 
455         /**
456          * Converts an arc to cubic Bezier segments and records them in p.
457          *
458          * @param cx The x coordinate center of the ellipse
459          * @param cy The y coordinate center of the ellipse
460          * @param rx The radius of the ellipse in the horizontal direction
461          * @param ry The radius of the ellipse in the vertical direction
462          * @param e1x E(eta1) x coordinate of the starting point of the arc
463          * @param e1y E(eta2) y coordinate of the starting point of the arc
464          * @param theta The angle that the ellipse bounding rectangle makes with horizontal plane
465          * @param start The start angle of the arc on the ellipse
466          * @param sweep The angle (positive or negative) of the sweep of the arc on the ellipse
467          */
arcToBeziernull468         private fun arcToBezier(
469             cx: Float,
470             cy: Float,
471             rx: Float,
472             ry: Float,
473             e1x: Float,
474             e1y: Float,
475             theta: Float,
476             start: Float,
477             sweep: Float
478         ): List<Cubic> {
479             val cubics = mutableListOf<Cubic>()
480 
481             // Taken from equations at: http://spaceroots.org/documents/ellipse/node8.html
482             // and http://www.spaceroots.org/documents/ellipse/node22.html
483             // Maximum of 45 degrees per cubic Bezier segment
484             var ce1x = e1x
485             var ce1y = e1y
486             val numSegments = (ceil(abs(sweep * 4 / PI))).toInt()
487 
488             var eta1 = start
489             val cosTheta = cos(theta)
490             val sinTheta = sin(theta)
491             val cosEta1 = cos(eta1)
492             val sinEta1 = sin(eta1)
493             var ep1x = (-rx * cosTheta * sinEta1) - (ry * sinTheta * cosEta1)
494             var ep1y = (-rx * sinTheta * sinEta1) + (ry * cosTheta * cosEta1)
495 
496             val anglePerSegment = sweep / numSegments
497             for (i in 0 until numSegments) {
498                 val eta2 = eta1 + anglePerSegment
499                 val sinEta2 = sin(eta2)
500                 val cosEta2 = cos(eta2)
501                 val e2x = cx + (rx * cosTheta * cosEta2) - (ry * sinTheta * sinEta2)
502                 val e2y = cy + (rx * sinTheta * cosEta2) + (ry * cosTheta * sinEta2)
503                 val ep2x = -rx * cosTheta * sinEta2 - ry * sinTheta * cosEta2
504                 val ep2y = -rx * sinTheta * sinEta2 + ry * cosTheta * cosEta2
505                 val tanDiff2 = tan((eta2 - eta1) / 2)
506                 val alpha = sin(eta2 - eta1) * (sqrt(4 + (3 * tanDiff2 * tanDiff2)) - 1) / 3
507                 val q1x = ce1x + alpha * ep1x
508                 val q1y = ce1y + alpha * ep1y
509                 val q2x = e2x - alpha * ep2x
510                 val q2y = e2y - alpha * ep2y
511 
512                 cubics.add(Cubic(ce1x, ce1y, q1x, q1y, q2x, q2y, e2x, e2y))
513                 eta1 = eta2
514                 ce1x = e2x
515                 ce1y = e2y
516                 ep1x = ep2x
517                 ep1y = ep2y
518             }
519             return cubics
520         }
521     }
522 }
523