Skip to content

Commit

Permalink
Add Path.toSvg()
Browse files Browse the repository at this point in the history
  • Loading branch information
romainguy committed Dec 15, 2022
1 parent b818420 commit 39ddedb
Show file tree
Hide file tree
Showing 7 changed files with 266 additions and 3 deletions.
14 changes: 14 additions & 0 deletions .idea/androidTestResultsUserPreferences.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions .idea/deploymentTargetDropDown.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@ repositories {
}
dependencies {
implementation 'dev.romainguy:pathway:0.9.0'
implementation 'dev.romainguy:pathway:0.10.0'
}
```

## Features

- [Paths from images](#paths-from-images)
- [Path division](#path-division)
- [Convert to SVG](#convert-to-svg)
- [Iterating over a Path](#iterating-over-a-path)

## Paths from images
Expand Down Expand Up @@ -59,6 +60,12 @@ val path = Path().apply {
val paths = path.divide()
```

## Convert to SVG

To convert a `Path` to an SVG document, call `Path.toSvg()`. If you only want the path data instead
of a full SVG document, use `Path.toSvg(document = false)` instead. Exporting a full document will
properly honor the path's fill type.

## Iterating over a Path

With Pathway you can easily iterate over a `Path` object to inspect its segments
Expand Down
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
GROUP=dev.romainguy
VERSION_NAME=0.9.0
VERSION_NAME=0.10.0

SONATYPE_HOST=S01
RELEASE_SIGNING_ENABLED=true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.math.abs

@RunWith(AndroidJUnit4::class)
class PathIteratorTest {
Expand Down
132 changes: 132 additions & 0 deletions pathway/src/androidTest/java/dev/romainguy/graphics/path/SvgTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/*
* Copyright (C) 2022 Romain Guy
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package dev.romainguy.graphics.path

import android.graphics.*
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class SvgTest {
@Test
fun emptyPath() {
assertEquals(
"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0.0 0.0 0.0 0.0">
</svg>
""".trimIndent(),
Path().toSvg()
)

assertTrue(Path().toSvg(document = false).isEmpty())
}

@Test
fun singleMove() {
val svg = Path().apply { moveTo(10.0f, 10.0f) }.toSvg()
assertEquals(
"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="10.0 10.0 0.0 0.0">
<path d="M10.0 10.0"/>
</svg>
""".trimIndent(),
svg
)
}

@Test
fun twoPaths() {
val svg = Path().apply {
addRect(0.0f, 0.0f, 10.0f, 10.0f, Path.Direction.CW)
addRect(20.0f, 20.0f, 50.0f, 50.0f, Path.Direction.CW)
}.toSvg()

assertEquals(
"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0.0 0.0 50.0 50.0">
<path d="M0.0 0.0L10.0 0.0 10.0 10.0 0.0 10.0ZM20.0 20.0L50.0 20.0 50.0 50.0 20.0 50.0Z"/>
</svg>
""".trimIndent(),
svg
)
}

@Test
fun dataOnly() {
val svg = Path().apply {
addRect(0.0f, 0.0f, 10.0f, 10.0f, Path.Direction.CW)
addRect(20.0f, 20.0f, 50.0f, 50.0f, Path.Direction.CW)
}.toSvg(document = false)

assertEquals(
"M0.0 0.0L10.0 0.0 10.0 10.0 0.0 10.0ZM20.0 20.0L50.0 20.0 50.0 50.0 20.0 50.0Z",
svg
)
}

@Test
fun curves() {
val svg = Path().apply {
addCircle(36.0f, 36.0f, 16.0f, Path.Direction.CW)
}.toSvg()

assertEquals(
"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="20.0 20.0 32.0 32.0">
<path d="M52.0 36.0Q51.999992 42.62741 47.3137 47.3137 42.62741 51.999992 36.0 52.0 29.372581 51.999992 24.68629 47.3137 19.999998 42.62741 20.0 36.0 19.999998 29.372581 24.68629 24.68629 29.372581 20.0 36.0 20.0 42.62741 20.0 47.3137 24.68629 51.999992 29.372581 52.0 36.0Z"/>
</svg>
""".trimIndent(),
svg
)
}

@Test
fun donuts() {
val donut = Path().apply {
addCircle(36.0f, 36.0f, 18.0f, Path.Direction.CW)
addCircle(36.0f, 36.0f, 8.0f, Path.Direction.CW)
}

assertEquals(
"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="18.0 18.0 36.0 36.0">
<path d="M54.0 36.0Q53.999992 39.580418 52.62982 42.888294 51.25965 46.19617 48.727917 48.727917 46.19617 51.25965 42.888294 52.62982 39.580418 53.999992 36.0 54.0 32.419575 53.999992 29.111696 52.62982 25.803814 51.25965 23.272076 48.727917 20.740335 46.19617 19.370167 42.888294 17.999998 39.580418 18.0 36.0 17.999998 32.419575 19.370167 29.111696 20.740335 25.803814 23.272076 23.272076 25.803814 20.740335 29.111694 19.370167 32.419575 18.0 36.0 18.0 39.580418 18.0 42.888294 19.370167 46.19617 20.740335 48.727917 23.272076 51.25965 25.803814 52.62982 29.111694 53.999992 32.419575 54.0 36.0ZM44.0 36.0Q44.0 39.31371 41.656853 41.656853 39.31371 44.0 36.0 44.0 32.686287 44.0 30.343143 41.656853 27.999998 39.31371 28.0 36.0 27.999998 32.686287 30.343143 30.343143 32.686287 28.0 36.0 28.0 39.31371 28.0 41.656853 30.343143 44.0 32.686287 44.0 36.0Z"/>
</svg>
""".trimIndent(),
donut.toSvg()
)

donut.fillType = Path.FillType.EVEN_ODD

assertEquals(
"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="18.0 18.0 36.0 36.0">
<path fill-rule="evenodd" d="M54.0 36.0Q53.999992 39.580418 52.62982 42.888294 51.25965 46.19617 48.727917 48.727917 46.19617 51.25965 42.888294 52.62982 39.580418 53.999992 36.0 54.0 32.419575 53.999992 29.111696 52.62982 25.803814 51.25965 23.272076 48.727917 20.740335 46.19617 19.370167 42.888294 17.999998 39.580418 18.0 36.0 17.999998 32.419575 19.370167 29.111696 20.740335 25.803814 23.272076 23.272076 25.803814 20.740335 29.111694 19.370167 32.419575 18.0 36.0 18.0 39.580418 18.0 42.888294 19.370167 46.19617 20.740335 48.727917 23.272076 51.25965 25.803814 52.62982 29.111694 53.999992 32.419575 54.0 36.0ZM44.0 36.0Q44.0 39.31371 41.656853 41.656853 39.31371 44.0 36.0 44.0 32.686287 44.0 30.343143 41.656853 27.999998 39.31371 28.0 36.0 27.999998 32.686287 30.343143 30.343143 32.686287 28.0 36.0 28.0 39.31371 28.0 41.656853 30.343143 44.0 32.686287 44.0 36.0Z"/>
</svg>
""".trimIndent(),
donut.toSvg()
)
}
}
94 changes: 94 additions & 0 deletions pathway/src/main/java/dev/romainguy/graphics/path/Svg.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*
* Copyright (C) 2022 Romain Guy
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

@file:JvmName("Svg")

package dev.romainguy.graphics.path

import android.graphics.Path
import android.graphics.RectF
import dev.romainguy.graphics.path.PathSegment.Type

fun Path.toSvg(document: Boolean = true) = buildString {
val bounds = RectF()
this@toSvg.computeBounds(bounds, true)

if (document) {
append("""<svg xmlns="http://www.w3.org/2000/svg" """)
appendLine("""viewBox="${bounds.left} ${bounds.top} ${bounds.width()} ${bounds.height()}">""")
}

val iterator = this@toSvg.iterator()
val points = FloatArray(8)
var lastType = Type.Done

if (iterator.hasNext()) {
if (document) {
if (this@toSvg.fillType == Path.FillType.EVEN_ODD) {
append(""" <path fill-rule="evenodd" d="""")
} else {
append(""" <path d="""")
}
}

while (iterator.hasNext()) {
val type = iterator.next(points)
when (type) {
Type.Move -> {
append("${command(Type.Move, lastType)}${points[0]} ${points[1]}")
}
Type.Line -> {
append("${command(Type.Line, lastType)}${points[2]} ${points[3]}")
}
Type.Quadratic -> {
append(command(Type.Quadratic, lastType))
append("${points[2]} ${points[3]} ${points[4]} ${points[5]}")
}
Type.Conic -> continue // We convert conics to quadratics
Type.Cubic -> {
append(command(Type.Cubic, lastType))
append("${points[2]} ${points[3]} ")
append("${points[4]} ${points[5]} ")
append("${points[6]} ${points[7]}")
}
Type.Close -> {
append(command(Type.Close, lastType))
}
Type.Done -> continue // Won't happen inside this loop
}
lastType = type
}

if (document) {
appendLine(""""/>""")
}
}
if (document) {
appendLine("""</svg>""")
}
}

private fun command(type: Type, lastType: Type) =
if (type != lastType) {
when (type) {
Type.Move -> "M"
Type.Line -> "L"
Type.Quadratic -> "Q"
Type.Cubic -> "C"
Type.Close -> "Z"
else -> ""
}
} else " "

0 comments on commit 39ddedb

Please sign in to comment.