All Downloads are FREE. Search and download functionalities are using the official Maven repository.

commonMain.svg.StringToPath.kt Maven / Gradle / Ivy

The newest version!
package com.juul.krayon.kanvas.svg

import com.juul.krayon.kanvas.Path
import com.juul.krayon.kanvas.RelativePathBuilder
import com.juul.krayon.kanvas.SegmentedPath
import com.juul.krayon.kanvas.SegmentedPathBuilder
import com.juul.krayon.kanvas.svg.Command.AbsoluteArc
import com.juul.krayon.kanvas.svg.Command.AbsoluteClosePath
import com.juul.krayon.kanvas.svg.Command.AbsoluteCubicTo
import com.juul.krayon.kanvas.svg.Command.AbsoluteHorizontalLineTo
import com.juul.krayon.kanvas.svg.Command.AbsoluteLineTo
import com.juul.krayon.kanvas.svg.Command.AbsoluteMoveTo
import com.juul.krayon.kanvas.svg.Command.AbsoluteQuadraticTo
import com.juul.krayon.kanvas.svg.Command.AbsoluteSmoothCubicTo
import com.juul.krayon.kanvas.svg.Command.AbsoluteSmoothQuadraticTo
import com.juul.krayon.kanvas.svg.Command.AbsoluteVerticalLineTo
import com.juul.krayon.kanvas.svg.Command.RelativeArc
import com.juul.krayon.kanvas.svg.Command.RelativeClosePath
import com.juul.krayon.kanvas.svg.Command.RelativeCubicTo
import com.juul.krayon.kanvas.svg.Command.RelativeHorizontalLineTo
import com.juul.krayon.kanvas.svg.Command.RelativeLineTo
import com.juul.krayon.kanvas.svg.Command.RelativeMoveTo
import com.juul.krayon.kanvas.svg.Command.RelativeQuadraticTo
import com.juul.krayon.kanvas.svg.Command.RelativeSmoothCubicTo
import com.juul.krayon.kanvas.svg.Command.RelativeSmoothQuadraticTo
import com.juul.krayon.kanvas.svg.Command.RelativeVerticalLineTo
import com.juul.krayon.kanvas.svg.Token.CommandToken
import com.juul.krayon.kanvas.svg.Token.ValueToken
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.ceil
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
import kotlin.math.tan

/** Parses `this` as [SVG Path data](https://www.w3.org/TR/SVG/paths.html#PathData) to a [Path], throwing an exception if the parsing fails. */
public fun String.toPath(): Path = Path(parse(lex(this)))

/** Parses `this` as [SVG Path data](https://www.w3.org/TR/SVG/paths.html#PathData) to a [Path], returning null if the parsing fails. */
public fun String.toPathOrNull(): Path? = try {
    toPath()
} catch (_: Exception) {
    null
}

// A -lot- of this class is left `internal` instead of `private` because `@VisibleForTesting` isn't available in Kotlin Multiplatform

internal enum class Command(
    val command: Char,
    val numArgs: Int,
) {
    AbsoluteMoveTo(command = 'M', numArgs = 2),
    AbsoluteClosePath(command = 'Z', numArgs = 0),
    AbsoluteLineTo(command = 'L', numArgs = 2),
    AbsoluteHorizontalLineTo(command = 'H', numArgs = 1),
    AbsoluteVerticalLineTo(command = 'V', numArgs = 1),
    AbsoluteCubicTo(command = 'C', numArgs = 6),
    AbsoluteSmoothCubicTo(command = 'S', numArgs = 4),
    AbsoluteQuadraticTo(command = 'Q', numArgs = 4),
    AbsoluteSmoothQuadraticTo(command = 'T', numArgs = 2),
    AbsoluteArc(command = 'A', numArgs = 7),
    RelativeMoveTo(command = 'm', numArgs = 2),
    RelativeClosePath(command = 'z', numArgs = 0),
    RelativeLineTo(command = 'l', numArgs = 2),
    RelativeHorizontalLineTo(command = 'h', numArgs = 1),
    RelativeVerticalLineTo(command = 'v', numArgs = 1),
    RelativeCubicTo(command = 'c', numArgs = 6),
    RelativeSmoothCubicTo(command = 's', numArgs = 4),
    RelativeQuadraticTo(command = 'q', numArgs = 4),
    RelativeSmoothQuadraticTo(command = 't', numArgs = 2),
    RelativeArc(command = 'a', numArgs = 7),
}

private val charToCommandCache = enumValues().associateBy { it.command }

private fun Char.toCommandOrNull(): Command? = charToCommandCache[this]

internal sealed class Token {
    data class CommandToken(val command: Command) : Token()

    data class ValueToken(val value: Float) : Token()
}

private fun Char.isDelimiter(): Boolean = this.isWhitespace() || this == ','

/** Lex the path string into a list of tokens. */
internal fun lex(string: String): Sequence = sequence {
    var tokenStart = 0
    var tokenStop = 0

    while (tokenStop < string.length) {
        val char = string[tokenStop]
        val isDelimiter = char.isDelimiter()
        val command = char.toCommandOrNull()

        if (isDelimiter || command != null) {
            if (tokenStop > tokenStart) {
                // Our read head just moved past the end of a value
                val value = string.substring(tokenStart, tokenStop).toFloat()
                yield(ValueToken(value))
            }

            if (command != null) {
                // Our read head found a command
                yield(CommandToken(command))
            }

            tokenStop += 1
            tokenStart = tokenStop
        } else {
            tokenStop += 1
        }
    }

    // Our source string ended while parsing a token
    if (tokenStop > tokenStart) {
        val value = string.substring(tokenStart).toFloat()
        yield(ValueToken(value))
    }
}

internal fun parse(tokens: Sequence): SegmentedPath {
    val builder = CommandPathBuilder()
    var command: Command? = null
    var valueRequired = false
    val values = mutableListOf()
    for (token in tokens) {
        if (token is CommandToken) {
            check(!valueRequired) { "Found command ${token.command}, but expected additional value for command ${command?.command}." }
            if (token.command.numArgs == 0) {
                builder.push(token.command, emptyList())
                command = null
            } else {
                command = token.command
                valueRequired = true
            }
        } else {
            checkNotNull(command) { "Found value token before first command token." }
            values += (token as ValueToken).value
            val consumeArguments = command.numArgs == values.size
            if (consumeArguments) {
                builder.push(command, values)
                values.clear()
            }
            valueRequired = !consumeArguments
        }
    }
    check(!valueRequired) { "Incorrect number of arguments for $command, expected ${command?.numArgs} but got ${values.size}." }
    return builder.build()
}

private class CommandPathBuilder : RelativePathBuilder() {

    private val delegate = SegmentedPathBuilder()

    private var lastControlPoint: Pair? = null

    override fun moveTo(x: Float, y: Float) {
        super.moveTo(x, y)
        delegate.moveTo(x, y)
        lastControlPoint = null
    }

    override fun lineTo(x: Float, y: Float) {
        super.lineTo(x, y)
        lastControlPoint = null
        delegate.lineTo(x, y)
    }

    override fun arcTo(left: Float, top: Float, right: Float, bottom: Float, startAngle: Float, sweepAngle: Float, forceMoveTo: Boolean) {
        super.arcTo(left, top, right, bottom, startAngle, sweepAngle, forceMoveTo)
        lastControlPoint = null
        delegate.arcTo(left, top, right, bottom, startAngle, sweepAngle, forceMoveTo)
    }

    override fun quadraticTo(controlX: Float, controlY: Float, endX: Float, endY: Float) {
        super.quadraticTo(controlX, controlY, endX, endY)
        lastControlPoint = controlX to controlY
        delegate.quadraticTo(controlX, controlY, endX, endY)
    }

    override fun cubicTo(beginControlX: Float, beginControlY: Float, endControlX: Float, endControlY: Float, endX: Float, endY: Float) {
        super.cubicTo(beginControlX, beginControlY, endControlX, endControlY, endX, endY)
        lastControlPoint = endControlX to endControlY
        delegate.cubicTo(beginControlX, beginControlY, endControlX, endControlY, endX, endY)
    }

    override fun close() {
        super.close()
        lastControlPoint = null
        delegate.close()
    }

    override fun reset() {
        super.reset()
        lastControlPoint = null
        delegate.reset()
    }

    override fun build(): SegmentedPath = delegate.build()

    fun push(command: Command, args: List) {
        when (command) {
            AbsoluteArc -> pushArc(command, args)
            AbsoluteClosePath -> close()
            AbsoluteCubicTo -> cubicTo(args[0], args[1], args[2], args[3], args[4], args[5])
            AbsoluteHorizontalLineTo -> lineTo(args[0], state.lastY)
            AbsoluteLineTo -> lineTo(args[0], args[1])
            AbsoluteMoveTo -> moveTo(args[0], args[1])
            AbsoluteQuadraticTo -> quadraticTo(args[0], args[1], args[2], args[3])
            AbsoluteSmoothCubicTo -> pushSmooth(command, args)
            AbsoluteSmoothQuadraticTo -> pushSmooth(command, args)
            AbsoluteVerticalLineTo -> lineTo(state.lastX, args[0])
            RelativeArc -> pushArc(command, args)
            RelativeClosePath -> close()
            RelativeCubicTo -> relativeCubicTo(args[0], args[1], args[2], args[3], args[4], args[5])
            RelativeHorizontalLineTo -> relativeLineTo(args[0], 0f)
            RelativeLineTo -> relativeLineTo(args[0], args[1])
            RelativeMoveTo -> relativeMoveTo(args[0], args[1])
            RelativeQuadraticTo -> relativeQuadraticTo(args[0], args[1], args[2], args[3])
            RelativeSmoothCubicTo -> pushSmooth(command, args)
            RelativeSmoothQuadraticTo -> pushSmooth(command, args)
            RelativeVerticalLineTo -> relativeLineTo(0f, args[0])
        }
    }

    private fun pushSmooth(command: Command, args: List) {
        val state = this.state // copy prevents re-allocating every read
        val lastControlPoint = this.lastControlPoint // copy helps with type inference
        if (lastControlPoint == null) {
            when (command) {
                AbsoluteSmoothCubicTo -> cubicTo(state.lastX, state.lastY, args[0], args[1], args[2], args[3])
                AbsoluteSmoothQuadraticTo -> quadraticTo(state.lastX, state.lastY, args[0], args[1])
                RelativeSmoothCubicTo -> relativeCubicTo(0f, 0f, args[0], args[1], args[2], args[3])
                RelativeSmoothQuadraticTo -> relativeQuadraticTo(0f, 0f, args[0], args[1])
                else -> error("Illegal command for `pushSmooth`: $command")
            }
        } else {
            val dx = state.lastX - lastControlPoint.first
            val dy = state.lastY - lastControlPoint.second
            when (command) {
                AbsoluteSmoothCubicTo -> cubicTo(state.lastX + dx, state.lastY + dy, args[0], args[1], args[2], args[3])
                AbsoluteSmoothQuadraticTo -> quadraticTo(state.lastX + dx, state.lastY + dy, args[0], args[1])
                RelativeSmoothCubicTo -> relativeCubicTo(dx, dy, args[0], args[1], args[2], args[3])
                RelativeSmoothQuadraticTo -> relativeQuadraticTo(dx, dy, args[0], args[1])
                else -> error("Illegal command for `pushSmooth`: $command")
            }
        }
    }

    private fun pushArc(command: Command, args: List) {
        check(command == AbsoluteArc || command == RelativeArc) {
            "Command must be one of AbsoluteArc or RelativeArc, but was $command."
        }
        val state = this.state // copy prevents re-allocating every read
        val rx = abs(args[0])
        val ry = abs(args[1])
        val x = args[5]
        val y = args[6]
        if (rx <= 0f || ry <= 0f) {
            // when radius is zero, treat it as infinite (straight line)
            when (command) {
                AbsoluteArc -> lineTo(x, y)
                RelativeArc -> relativeLineTo(x, y)
                else -> error("Unreachable.")
            }
        } else {
            val xAxisRotation = args[2]
            val largeArcFlag = args[3] != 0f
            val sweepFlag = args[4] != 0f
            pushArc(
                x0 = state.lastX,
                y0 = state.lastY,
                x1 = x + if (command == RelativeArc) state.lastX else 0f,
                y1 = y + if (command == RelativeArc) state.lastY else 0f,
                a = rx,
                b = ry,
                thetaDegrees = xAxisRotation,
                isMoreThanHalf = largeArcFlag,
                isPositiveArc = sweepFlag,
            )
        }
    }

    /** Translated from Android's PathParser: https://android.googlesource.com/platform/frameworks/base/+/17e64ffd852f8fe23b8e2e2ff1b62ee742af17a6/core/java/android/util/PathParser.java#381 */
    private fun pushArc(
        x0: Float,
        y0: Float,
        x1: Float,
        y1: Float,
        a: Float,
        b: Float,
        thetaDegrees: Float,
        isMoreThanHalf: Boolean,
        isPositiveArc: Boolean,
    ) {
        val theta = thetaDegrees * PI.toFloat() / 180
        // Pre-compute rotation matrix entries
        val cosTheta = cos(theta)
        val sinTheta = sin(theta)
        // Transform (x0, y0) and (x1, y1) into unit space using (inverse) rotation, followed by (inverse) scale
        val x0p = (x0 * cosTheta + y0 * sinTheta) / a
        val y0p = (-x0 * sinTheta + y0 * cosTheta) / b
        val x1p = (x1 * cosTheta + y1 * sinTheta) / a
        val y1p = (-x1 * sinTheta + y1 * cosTheta) / b

        // Compute differences and averages
        val dx = x0p - x1p
        val dy = y0p - y1p
        val xm = (x0p + x1p) / 2
        val ym = (y0p + y1p) / 2

        // Solve for intersecting unit circles
        val dsq = dx * dx + dy * dy
        if (dsq == 0f) {
            return // Points are coincident
        }
        val disc = 1f / dsq - 1f / 4f
        if (disc < 0f) {
            // Target position is too far apart, so scale the ellipse while preserving the requested aspect ratio.
            val adjust = sqrt(dsq) / 1.99999f // I have no idea why the original source uses 1.99999 instead of 2
            pushArc(x0, y0, x1, y1, a * adjust, b * adjust, thetaDegrees, isMoreThanHalf, isPositiveArc)
            return // Don't finish calculation here because we recursed
        }
        val s = sqrt(disc)
        val sdx = s * dx
        val sdy = s * dy
        var (cx, cy) = when (isMoreThanHalf == isPositiveArc) {
            true -> (xm - sdy) to (ym + sdx)
            false -> (xm + sdy) to (ym - sdx)
        }

        val eta0 = atan2(y0p - cy, x0p - cx)
        val eta1 = atan2(y1p - cy, x1p - cx)

        val sweep = run {
            val sweep = eta1 - eta0
            when {
                isPositiveArc == (sweep >= 0) -> sweep
                sweep > 0 -> sweep - 2 * PI.toFloat()
                else -> sweep + 2 * PI.toFloat()
            }
        }

        cx *= a
        cy *= b
        val tcx = cx
        cx = cx * cosTheta - cy * sinTheta
        cy = tcx * sinTheta + cy * cosTheta

        arcToCubic(cx, cy, a, b, x0, y0, thetaDegrees, eta0, sweep)
    }

    /** Translated from Android's PathParser. https://android.googlesource.com/platform/frameworks/base/+/17e64ffd852f8fe23b8e2e2ff1b62ee742af17a6/core/java/android/util/PathParser.java#472 */
    private fun arcToCubic(
        cx: Float,
        cy: Float,
        a: Float,
        b: Float,
        e1x: Float,
        e1y: Float,
        theta: Float,
        start: Float,
        sweep: Float,
    ) {
        // Shadow some parameters because Java implementation was mutating them
        var e1x = e1x
        var e1y = e1y
        // Maximum of 45 degrees per cubic Bezier segment
        val numSegments = abs(ceil(sweep * 4 / PI).toInt())
        var eta1 = start
        val cosTheta = cos(theta)
        val sinTheta = sin(theta)
        val cosEta1 = cos(eta1)
        val sinEta1 = sin(eta1)
        var ep1x = -a * cosTheta * sinEta1 - b * sinTheta * cosEta1
        var ep1y = -a * sinTheta * sinEta1 + b * cosTheta * cosEta1
        val anglePerSegment = sweep / numSegments
        for (i in 0 until numSegments) {
            val eta2 = eta1 + anglePerSegment
            val sinEta2 = sin(eta2)
            val cosEta2 = cos(eta2)
            val e2x = cx + a * cosTheta * cosEta2 - b * sinTheta * sinEta2
            val e2y = cy + a * sinTheta * cosEta2 + b * cosTheta * sinEta2
            val ep2x = -a * cosTheta * sinEta2 - b * sinTheta * cosEta2
            val ep2y = -a * sinTheta * sinEta2 + b * cosTheta * cosEta2
            val tanDiff2 = tan((eta2 - eta1) / 2)
            val alpha = sin(eta2 - eta1) * (sqrt(4 + 3 * tanDiff2 * tanDiff2) - 1) / 3
            val q1x = e1x + alpha * ep1x
            val q1y = e1y + alpha * ep1y
            val q2x = e2x - alpha * ep2x
            val q2y = e2y - alpha * ep2y
            cubicTo(q1x, q1y, q2x, q2y, e2x, e2y)
            eta1 = eta2
            e1x = e2x
            e1y = e2y
            ep1x = ep2x
            ep1y = ep2y
        }
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy