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