[orx-composition, orx-svg] Move Composition and SVG code from OPENRNDR to ORX
This commit is contained in:
430
orx-svg/src/jvmMain/kotlin/SVGElement.kt
Normal file
430
orx-svg/src/jvmMain/kotlin/SVGElement.kt
Normal file
@@ -0,0 +1,430 @@
|
||||
package org.openrndr.extra.svg
|
||||
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.jsoup.nodes.*
|
||||
import org.openrndr.extra.composition.*
|
||||
import org.openrndr.math.*
|
||||
import org.openrndr.shape.*
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
internal sealed class SVGElement(element: Element?) {
|
||||
var tag: String = element?.tagName() ?: ""
|
||||
var id: String = element?.id() ?: ""
|
||||
|
||||
open var style = Style()
|
||||
abstract fun handleAttribute(attribute: Attribute)
|
||||
|
||||
// Any element can have a style attribute to pass down properties
|
||||
fun styleProperty(key: String, value: String) {
|
||||
when (key) {
|
||||
Prop.STROKE -> style.stroke = SVGParse.color(value)
|
||||
Prop.STROKE_OPACITY -> style.strokeOpacity = SVGParse.number(value)
|
||||
Prop.STROKE_WIDTH -> style.strokeWeight = SVGParse.length(value)
|
||||
Prop.STROKE_MITERLIMIT -> style.miterLimit = SVGParse.number(value)
|
||||
Prop.STROKE_LINECAP -> style.lineCap = SVGParse.lineCap(value)
|
||||
Prop.STROKE_LINEJOIN -> style.lineJoin = SVGParse.lineJoin(value)
|
||||
Prop.FILL -> style.fill = SVGParse.color(value)
|
||||
Prop.FILL_OPACITY -> style.fillOpacity = SVGParse.number(value)
|
||||
Prop.OPACITY -> style.opacity = SVGParse.number(value)
|
||||
else -> logger.warn { "Unknown property: $key" }
|
||||
}
|
||||
}
|
||||
|
||||
/** Special case of parsing an inline style attribute. */
|
||||
fun inlineStyles(attribute: Attribute) {
|
||||
attribute.value.split(";").forEach {
|
||||
val result = it.split(":").map { s -> s.trim() }
|
||||
|
||||
if (result.size >= 2) {
|
||||
styleProperty(result[0], result[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** <svg> element */
|
||||
internal class SVGSVGElement(element: Element) : SVGGroup(element) {
|
||||
var documentStyle: DocumentStyle = DocumentStyle()
|
||||
|
||||
init {
|
||||
documentStyle.viewBox = SVGParse.viewBox(this.element)
|
||||
documentStyle.preserveAspectRatio = SVGParse.preserveAspectRatio(this.element)
|
||||
}
|
||||
|
||||
var bounds = SVGParse.bounds(this.element)
|
||||
}
|
||||
|
||||
/** <g> element but practically works with anything that has child elements */
|
||||
internal open class SVGGroup(val element: Element, val elements: MutableList<SVGElement> = mutableListOf()) :
|
||||
SVGElement(element) {
|
||||
|
||||
init {
|
||||
this.element.attributes().forEach {
|
||||
if (it.key == Attr.STYLE) {
|
||||
inlineStyles(it)
|
||||
} else {
|
||||
handleAttribute(it)
|
||||
}
|
||||
}
|
||||
|
||||
handleChildren()
|
||||
}
|
||||
|
||||
private fun handleChildren() {
|
||||
this.element.children().forEach { child ->
|
||||
when (child.tagName()) {
|
||||
in Tag.graphicsList -> elements.add(SVGPath(child))
|
||||
else -> elements.add(SVGGroup(child))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleAttribute(attribute: Attribute) {
|
||||
when (attribute.key) {
|
||||
// Attributes can also be style properties, in which case they're passed on
|
||||
in Prop.list -> styleProperty(attribute.key, attribute.value)
|
||||
Attr.TRANSFORM -> style.transform = SVGParse.transform(this.element)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal class Command(val op: String, vararg val operands: Double) {
|
||||
fun asVectorList(): List<Vector2>? {
|
||||
return if (operands.size % 2 == 0) {
|
||||
operands.asList().chunked(2) { Vector2(it[0], it[1]) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For evaluating elliptical arc arguments according to the SVG spec
|
||||
internal fun Double.toBoolean(): Boolean? = when (this) {
|
||||
0.0 -> false
|
||||
1.0 -> true
|
||||
else -> null
|
||||
}
|
||||
|
||||
internal class SVGPath(val element: Element? = null) : SVGElement(element) {
|
||||
val commands = mutableListOf<Command>()
|
||||
|
||||
private fun compounds(): List<SVGPath> {
|
||||
val compounds = mutableListOf<SVGPath>()
|
||||
val compoundIndices = mutableListOf<Int>()
|
||||
|
||||
commands.forEachIndexed { index, it ->
|
||||
if (it.op == "M" || it.op == "m") {
|
||||
compoundIndices.add(index)
|
||||
}
|
||||
}
|
||||
|
||||
compoundIndices.forEachIndexed { index, _ ->
|
||||
val cs = compoundIndices[index]
|
||||
val ce = if (index + 1 < compoundIndices.size) (compoundIndices[index + 1]) else commands.size
|
||||
|
||||
// TODO: We shouldn't be making new SVGPaths without Elements to provide.
|
||||
// Then we could make SVGPath's constructor non-nullable
|
||||
val path = SVGPath()
|
||||
path.commands.addAll(commands.subList(cs, ce))
|
||||
|
||||
compounds.add(path)
|
||||
}
|
||||
return compounds
|
||||
}
|
||||
|
||||
fun shape(): Shape {
|
||||
var cursor = Vector2.ZERO
|
||||
var anchor = Vector2.ZERO
|
||||
// Still problematic
|
||||
var prevCubicCtrlPoint: Vector2? = null
|
||||
var prevQuadCtrlPoint: Vector2? = null
|
||||
|
||||
val contours = compounds().map { compound ->
|
||||
val segments = mutableListOf<Segment>()
|
||||
var closed = false
|
||||
// If an argument is invalid, an error is logged,
|
||||
// further interpreting is stopped and compound is returned as-is.
|
||||
compound.commands.forEach { command ->
|
||||
|
||||
if (command.op !in listOf("z", "Z") && command.operands.isEmpty()) {
|
||||
logger.error { "Invalid amount of arguments provided for: ${command.op}" }
|
||||
return@forEach
|
||||
}
|
||||
|
||||
val points = command.asVectorList()
|
||||
|
||||
// TODO: Rethink this check
|
||||
if (points == null && command.op.lowercase() !in listOf("a", "h", "v")) {
|
||||
logger.error { "Invalid amount of arguments provided for: ${command.op}" }
|
||||
return@forEach
|
||||
}
|
||||
|
||||
when (command.op) {
|
||||
"A", "a" -> {
|
||||
// If size == step, only the last window can be partial
|
||||
// Special case as it also has boolean values
|
||||
val contours = command.operands.toList().windowed(7, 7, true).map m@{
|
||||
if (it.size != 7) {
|
||||
logger.error { "Invalid amount of arguments provided for: ${command.op}" }
|
||||
return@forEach
|
||||
} else {
|
||||
val rx = it[0]
|
||||
val ry = it[1]
|
||||
val xAxisRot = it[2]
|
||||
val largeArcFlag = it[3].toBoolean()
|
||||
val sweepFlag = it[4].toBoolean()
|
||||
|
||||
if (largeArcFlag == null || sweepFlag == null || rx == 0.0 || ry == 0.0) {
|
||||
logger.error { "Invalid values provided for: ${command.op}" }
|
||||
return@forEach
|
||||
}
|
||||
|
||||
val end = Vector2(it[5], it[6]).let { v ->
|
||||
if (command.op == "a") {
|
||||
v + cursor
|
||||
} else {
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
contour {
|
||||
moveTo(cursor)
|
||||
arcTo(rx, ry, xAxisRot, largeArcFlag, sweepFlag, end)
|
||||
cursor = end
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// I don't know why we can't just have segments from the above map,
|
||||
// but this is the only way this works.
|
||||
segments += contours.flatMap { it.segments}
|
||||
}
|
||||
"M" -> {
|
||||
// TODO: Log an error when this nulls
|
||||
cursor = points!!.firstOrNull() ?: return@forEach
|
||||
anchor = cursor
|
||||
|
||||
// Following points are implicit lineto arguments
|
||||
segments += points.drop(1).map {
|
||||
Segment(cursor, it).apply {
|
||||
cursor = it
|
||||
}
|
||||
}
|
||||
}
|
||||
"m" -> {
|
||||
// TODO: Log an error when this nulls
|
||||
cursor += points!!.firstOrNull() ?: return@forEach
|
||||
anchor = cursor
|
||||
|
||||
// Following points are implicit lineto arguments
|
||||
segments += points.drop(1).map {
|
||||
Segment(cursor, cursor + it).apply {
|
||||
cursor += it
|
||||
}
|
||||
}
|
||||
}
|
||||
"L" -> {
|
||||
segments += points!!.map {
|
||||
Segment(cursor, it).apply {
|
||||
cursor = it
|
||||
}
|
||||
}
|
||||
}
|
||||
"l" -> {
|
||||
segments += points!!.map {
|
||||
Segment(cursor, cursor + it).apply {
|
||||
cursor += it
|
||||
}
|
||||
}
|
||||
}
|
||||
"H" -> {
|
||||
segments += command.operands.map {
|
||||
val target = Vector2(it, cursor.y)
|
||||
Segment(cursor, target).apply {
|
||||
cursor = target
|
||||
}
|
||||
}
|
||||
}
|
||||
"h" -> {
|
||||
segments += command.operands.map {
|
||||
val target = cursor + Vector2(it, 0.0)
|
||||
Segment(cursor, target).apply {
|
||||
cursor = target
|
||||
}
|
||||
}
|
||||
}
|
||||
"V" -> {
|
||||
segments += command.operands.map {
|
||||
val target = Vector2(cursor.x, it)
|
||||
Segment(cursor, target).apply {
|
||||
cursor = target
|
||||
}
|
||||
}
|
||||
}
|
||||
"v" -> {
|
||||
segments += command.operands.map {
|
||||
val target = cursor + Vector2(0.0, it)
|
||||
Segment(cursor, target).apply {
|
||||
cursor = target
|
||||
}
|
||||
}
|
||||
}
|
||||
"C" -> {
|
||||
segments += points!!.windowed(3, 3, true).map {
|
||||
if (it.size != 3) {
|
||||
logger.error { "Invalid amount of arguments provided for: ${command.op}" }
|
||||
return@forEach
|
||||
} else {
|
||||
val (cp1, cp2, target) = it
|
||||
Segment(cursor, cp1, cp2, target).also {
|
||||
cursor = target
|
||||
prevCubicCtrlPoint = cp2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"c" -> {
|
||||
segments += points!!.windowed(3, 3, true).map {
|
||||
if (it.size != 3) {
|
||||
logger.error { "Invalid amount of arguments provided for: ${command.op}" }
|
||||
return@forEach
|
||||
} else {
|
||||
val (cp1, cp2, target) = it.map { v -> cursor + v }
|
||||
Segment(cursor, cp1, cp2, target).apply {
|
||||
cursor = target
|
||||
prevCubicCtrlPoint = cp2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"S" -> {
|
||||
segments += points!!.windowed(2, 2, true).map {
|
||||
if (it.size != 2) {
|
||||
logger.error { "Invalid amount of arguments provided for: ${command.op}" }
|
||||
return@forEach
|
||||
} else {
|
||||
val cp1 = 2.0 * cursor - (prevCubicCtrlPoint ?: cursor)
|
||||
val (cp2, target) = it
|
||||
Segment(cursor, cp1, cp2, target).also {
|
||||
cursor = target
|
||||
prevCubicCtrlPoint = cp2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"s" -> {
|
||||
segments += points!!.windowed(2, 2, true).map {
|
||||
if (it.size != 2) {
|
||||
logger.error { "Invalid amount of arguments provided for: ${command.op}" }
|
||||
return@forEach
|
||||
} else {
|
||||
val cp1 = 2.0 * cursor - (prevCubicCtrlPoint ?: cursor)
|
||||
val (cp2, target) = it.map { v -> cursor + v }
|
||||
Segment(cursor, cp1, cp2, target).also {
|
||||
cursor = target
|
||||
prevCubicCtrlPoint = cp2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"Q" -> {
|
||||
segments += points!!.windowed(2, 2, true).map {
|
||||
if (it.size != 2) {
|
||||
logger.error { "Invalid amount of arguments provided for: ${command.op}" }
|
||||
return@forEach
|
||||
} else {
|
||||
val (cp, target) = it
|
||||
Segment(cursor, cp, target).also {
|
||||
cursor = target
|
||||
prevQuadCtrlPoint = cp
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"q" -> {
|
||||
segments += points!!.windowed(2, 2, true).map {
|
||||
if (it.size != 2) {
|
||||
logger.error { "Invalid amount of arguments provided for: ${command.op}" }
|
||||
return@forEach
|
||||
} else {
|
||||
val (cp, target) = it.map { v -> cursor + v }
|
||||
Segment(cursor, cp, target).also {
|
||||
cursor = target
|
||||
prevQuadCtrlPoint = cp
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"T" -> {
|
||||
points!!.forEach {
|
||||
val cp = 2.0 * cursor - (prevQuadCtrlPoint ?: cursor)
|
||||
Segment(cursor, cp, it).also { _ ->
|
||||
cursor = it
|
||||
prevQuadCtrlPoint = cp
|
||||
}
|
||||
}
|
||||
}
|
||||
"t" -> {
|
||||
points!!.forEach {
|
||||
val cp = 2.0 * cursor - (prevQuadCtrlPoint ?: cursor)
|
||||
Segment(cursor, cp, cursor + it).also { _ ->
|
||||
cursor = it
|
||||
prevQuadCtrlPoint = cp
|
||||
}
|
||||
}
|
||||
}
|
||||
"Z", "z" -> {
|
||||
if ((cursor - anchor).length >= 0.001) {
|
||||
segments += Segment(cursor, anchor)
|
||||
}
|
||||
cursor = anchor
|
||||
closed = true
|
||||
}
|
||||
else -> {
|
||||
// The spec declares we should still attempt to render
|
||||
// the path up until the erroneous command as to visually
|
||||
// signal the user where the error occurred.
|
||||
logger.error { "Invalid path operator: ${command.op}" }
|
||||
return@forEach
|
||||
}
|
||||
}
|
||||
}
|
||||
ShapeContour(segments, closed, YPolarity.CW_NEGATIVE_Y)
|
||||
}
|
||||
return Shape(contours)
|
||||
}
|
||||
|
||||
override fun handleAttribute(attribute: Attribute) {
|
||||
if (this.element is Element) {
|
||||
when (attribute.key) {
|
||||
// Attributes can also be style properties, in which case they're passed on
|
||||
in Prop.list -> styleProperty(attribute.key, attribute.value)
|
||||
Attr.TRANSFORM -> style.transform = SVGParse.transform(this.element)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
if (this.element is Element) {
|
||||
commands += when (tag) {
|
||||
Tag.PATH -> SVGParse.path(this.element)
|
||||
Tag.LINE -> SVGParse.line(this.element)
|
||||
Tag.RECT -> SVGParse.rectangle(this.element)
|
||||
Tag.ELLIPSE -> SVGParse.ellipse(this.element)
|
||||
Tag.CIRCLE -> SVGParse.circle(this.element)
|
||||
Tag.POLYGON -> SVGParse.polygon(this.element)
|
||||
Tag.POLYLINE -> SVGParse.polyline(this.element)
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
element.attributes().forEach {
|
||||
if (it.key == Attr.STYLE) {
|
||||
inlineStyles(it)
|
||||
} else {
|
||||
handleAttribute(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
105
orx-svg/src/jvmMain/kotlin/SVGLoader.kt
Normal file
105
orx-svg/src/jvmMain/kotlin/SVGLoader.kt
Normal file
@@ -0,0 +1,105 @@
|
||||
package org.openrndr.extra.svg
|
||||
import org.jsoup.*
|
||||
import org.jsoup.parser.*
|
||||
import org.openrndr.extra.composition.*
|
||||
import org.openrndr.shape.*
|
||||
import java.io.*
|
||||
import java.net.*
|
||||
|
||||
/**
|
||||
* Load a [Composition] from a filename, url or svg string
|
||||
* @param fileOrUrlOrSvg a filename, a url or an svg document
|
||||
*/
|
||||
fun loadSVG(fileOrUrlOrSvg: String): Composition {
|
||||
return if (fileOrUrlOrSvg.endsWith(".svg")) {
|
||||
try {
|
||||
val url = URL(fileOrUrlOrSvg)
|
||||
parseSVG(url.readText())
|
||||
} catch (e: MalformedURLException) {
|
||||
parseSVG(File(fileOrUrlOrSvg).readText())
|
||||
}
|
||||
} else {
|
||||
parseSVG(fileOrUrlOrSvg)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a [Composition] from a file, url or svg string
|
||||
* @param file a filename, a url or an svg document
|
||||
*/
|
||||
fun loadSVG(file: File): Composition {
|
||||
return parseSVG(file.readText())
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an SVG document and creates a [Composition]
|
||||
* @param svgString xml-like svg document
|
||||
*/
|
||||
fun parseSVG(svgString: String): Composition {
|
||||
val document = SVGLoader().loadSVG(svgString)
|
||||
return document.composition()
|
||||
}
|
||||
|
||||
// internal class SVGImage(val url: String, val x: Double?, val y: Double?, val width: Double?, val height: Double?) : SVGElement()
|
||||
|
||||
internal class SVGDocument(private val root: SVGSVGElement, val namespaces: Map<String, String>) {
|
||||
fun composition(): Composition = Composition(
|
||||
convertElement(root),
|
||||
root.bounds
|
||||
|
||||
).apply {
|
||||
namespaces.putAll(this@SVGDocument.namespaces)
|
||||
this.documentStyle = this@SVGDocument.root.documentStyle
|
||||
}
|
||||
|
||||
private fun convertElement(svgElem: SVGElement): CompositionNode = when (svgElem) {
|
||||
is SVGGroup -> GroupNode().apply {
|
||||
this.id = svgElem.id
|
||||
svgElem.elements.mapTo(children) { convertElement(it).also { x -> x.parent = this@apply } }
|
||||
}
|
||||
is SVGPath -> {
|
||||
ShapeNode(svgElem.shape()).apply {
|
||||
style = svgElem.style
|
||||
this.id = svgElem.id
|
||||
}
|
||||
}
|
||||
}.apply {
|
||||
transform = svgElem.style.transform.value
|
||||
}
|
||||
}
|
||||
|
||||
internal class SVGLoader {
|
||||
fun loadSVG(svg: String): SVGDocument {
|
||||
val doc = Jsoup.parse(svg, "", Parser.xmlParser())
|
||||
val root = doc.select(Tag.SVG).first() ?: error("no root")
|
||||
val namespaces = root.attributes().filter { it.key.startsWith("xmlns") }.associate {
|
||||
Pair(it.key, it.value)
|
||||
}
|
||||
val rootGroup = SVGSVGElement(root)
|
||||
return SVGDocument(rootGroup, namespaces)
|
||||
}
|
||||
|
||||
// private fun handleImage(group: SVGGroup, e: Element) {
|
||||
// val width = e.attr(Attr.WIDTH).toDoubleOrNull()
|
||||
// val height = e.attr(Attr.HEIGHT).toDoubleOrNull()
|
||||
// val x = e.attr("x").toDoubleOrNull()
|
||||
// val y = e.attr("y").toDoubleOrNull()
|
||||
// val imageData = e.attr("xlink:href")
|
||||
// val image = ColorBuffer.fromUrl(imageData)
|
||||
// val imageNode = ImageNode(image, width ?: image.width.toDouble(), height ?: image.height.toDouble())
|
||||
// val image = SVGImage(imageData, x, y, width, height)
|
||||
// image.parseTransform(e)
|
||||
// group.elements.add(image)
|
||||
// }
|
||||
|
||||
// private fun handleImage(group: SVGGroup, e: Element) {
|
||||
// val width = e.attr("width").toDouble()
|
||||
// val height = e.attr("height").toDouble()
|
||||
// val url = e.attr("xlink:href")
|
||||
// val image = SVGImage(url).apply {
|
||||
// id = e.id()
|
||||
// parseTransform(e)
|
||||
// }
|
||||
// image.id = e.id()
|
||||
// }
|
||||
}
|
||||
498
orx-svg/src/jvmMain/kotlin/SVGParse.kt
Normal file
498
orx-svg/src/jvmMain/kotlin/SVGParse.kt
Normal file
@@ -0,0 +1,498 @@
|
||||
package org.openrndr.extra.svg
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.jsoup.nodes.*
|
||||
import org.openrndr.color.*
|
||||
import org.openrndr.extra.composition.*
|
||||
import org.openrndr.math.*
|
||||
import org.openrndr.math.transforms.*
|
||||
import org.openrndr.shape.*
|
||||
import java.util.regex.*
|
||||
import kotlin.math.*
|
||||
import kotlin.text.MatchResult
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
internal sealed interface PropertyRegex {
|
||||
|
||||
val regex: Regex
|
||||
|
||||
companion object {
|
||||
val wsp = "(?:\\s|\\A|\\Z)+".toRegex()
|
||||
val commaWsp = "(?:\\s*,\\s*|\\s+)".toRegex()
|
||||
const val align = "(?<align>[xy](?:Min|Mid|Max)[XY](?:Min|Mid|Max))?"
|
||||
const val meetOrSlice = "(?<meetOrSlice>meet|slice)?"
|
||||
const val unitIdentifier = "in|pc|pt|px|cm|mm|Q"
|
||||
val opts = RegexOption.IGNORE_CASE
|
||||
}
|
||||
|
||||
object Any : PropertyRegex {
|
||||
override val regex = ".+".toRegex()
|
||||
}
|
||||
|
||||
object Number : PropertyRegex {
|
||||
override val regex = "[+-]?(?:\\d+(?:\\.\\d+)?|\\.\\d+)(?:[eE][+-]?\\d+)?".toRegex()
|
||||
}
|
||||
|
||||
object NumberList : PropertyRegex {
|
||||
override val regex = "(?:${Number.regex}$commaWsp${Number.regex}$commaWsp?)+".toRegex()
|
||||
}
|
||||
|
||||
object Length : PropertyRegex {
|
||||
override val regex = "(?<number>${Number.regex})(?<ident>$unitIdentifier)?".toRegex(opts)
|
||||
}
|
||||
|
||||
object Percentage : PropertyRegex {
|
||||
override val regex = "${Number.regex}%".toRegex()
|
||||
}
|
||||
|
||||
object LengthOrPercentage : PropertyRegex {
|
||||
override val regex = "${Length.regex}|${Percentage.regex}".toRegex(opts)
|
||||
}
|
||||
|
||||
object PreserveAspectRatio : PropertyRegex {
|
||||
// We don't care for "defer", but if it's there, we'll ignore it.
|
||||
override val regex = "$wsp(?:defer)?$wsp${align}$wsp${meetOrSlice}$wsp".toRegex(opts)
|
||||
}
|
||||
|
||||
object RGBHex : PropertyRegex {
|
||||
override val regex = "#?([0-9a-f]{3,6})".toRegex(opts)
|
||||
}
|
||||
|
||||
object RGBFunctional : PropertyRegex {
|
||||
// Matches rgb(255, 255, 255)
|
||||
private val rgb8BitRegex = "(${Number.regex})${commaWsp}(${Number.regex})${commaWsp}(${Number.regex})"
|
||||
|
||||
// Matches rgb(100%, 100%, 100%)
|
||||
private val rgbPercentageRegex = "(${Number.regex})%${commaWsp}(${Number.regex})%${commaWsp}(${Number.regex})%"
|
||||
|
||||
override val regex = "${wsp}rgb\\(\\s*(?>$rgb8BitRegex\\s*|\\s*$rgbPercentageRegex)\\s*\\)$wsp".toRegex(opts)
|
||||
}
|
||||
}
|
||||
|
||||
internal object SVGParse {
|
||||
fun viewBox(element: Element): ViewBox {
|
||||
val viewBoxValue = element.attr(Attr.VIEW_BOX).trim()
|
||||
val (minX, minY, width, height) = PropertyRegex.NumberList.regex.matches(viewBoxValue).let {
|
||||
if (!it) {
|
||||
return ViewBox.None
|
||||
}
|
||||
val list = viewBoxValue.split(PropertyRegex.commaWsp).map(String::toDouble)
|
||||
when (list.size) {
|
||||
// Early return and signal that the element should not be rendered at all
|
||||
1 -> if (list[0] == 0.0) {
|
||||
return ViewBox.None
|
||||
} else {
|
||||
// Interpret as height
|
||||
listOf(0.0, 0.0, 0.0, list[0])
|
||||
}
|
||||
2 -> listOf(0.0, 0.0, list[0], list[1])
|
||||
3 -> listOf(0.0, list[0], list[1], list[2])
|
||||
4 -> list
|
||||
else -> return ViewBox.None
|
||||
}
|
||||
}
|
||||
|
||||
return ViewBox.Value(Rectangle(minX, minY, width.coerceAtLeast(0.0), height.coerceAtLeast(0.0)))
|
||||
}
|
||||
|
||||
fun preserveAspectRatio(element: Element): AspectRatio {
|
||||
val aspectRatioValue = element.attr(Attr.PRESERVE_ASPECT_RATIO)
|
||||
|
||||
val (alignmentValue, meetValue) = PropertyRegex.PreserveAspectRatio.regex.matchEntire(aspectRatioValue).let {
|
||||
val value = (it?.groups as? MatchNamedGroupCollection)?.get("align")?.value
|
||||
val type = (it?.groups as? MatchNamedGroupCollection)?.get("meetOrSlice")?.value
|
||||
|
||||
value to type
|
||||
}
|
||||
|
||||
val meet = when (meetValue) {
|
||||
"slice" -> MeetOrSlice.SLICE
|
||||
// Lacuna value
|
||||
else -> MeetOrSlice.MEET
|
||||
}
|
||||
|
||||
return when (alignmentValue) {
|
||||
"none" -> AspectRatio(Align.NONE, meet)
|
||||
"xMinYMin" -> AspectRatio(Align.X_MIN_Y_MIN, meet)
|
||||
"xMidYMin" -> AspectRatio(Align.X_MID_Y_MIN, meet)
|
||||
"xMaxYMin" -> AspectRatio(Align.X_MAX_Y_MIN, meet)
|
||||
"xMinYMid" -> AspectRatio(Align.X_MIN_Y_MID, meet)
|
||||
"xMidYMid" -> AspectRatio(Align.X_MID_Y_MID, meet)
|
||||
"xMaxYMid" -> AspectRatio(Align.X_MAX_Y_MID, meet)
|
||||
"xMinYMax" -> AspectRatio(Align.X_MIN_Y_MAX, meet)
|
||||
"xMidYMax" -> AspectRatio(Align.X_MID_Y_MAX, meet)
|
||||
"xMaxYMax" -> AspectRatio(Align.X_MAX_Y_MAX, meet)
|
||||
else -> AspectRatio(Align.X_MID_Y_MID, meet)
|
||||
}
|
||||
}
|
||||
|
||||
fun bounds(element: Element): CompositionDimensions {
|
||||
val values = listOf(Attr.X, Attr.Y, Attr.WIDTH, Attr.HEIGHT).map { attribute ->
|
||||
element.attr(attribute).let {
|
||||
it.ifEmpty { "0" }
|
||||
}
|
||||
}
|
||||
|
||||
// There's no way this'll throw an OOB, right?
|
||||
val (x, y, width, height) = values.map { str ->
|
||||
PropertyRegex.Length.regex.matchEntire(str).let {
|
||||
val value = (it?.groups as? MatchNamedGroupCollection)?.get("number")?.value?.toDouble() ?: 0.0
|
||||
val type = Length.UnitIdentifier.valueOf(
|
||||
(it?.groups as? MatchNamedGroupCollection)?.get("ident")?.value?.uppercase() ?: "PX"
|
||||
)
|
||||
|
||||
when (type) {
|
||||
Length.UnitIdentifier.IN -> Length.Pixels.fromInches(value)
|
||||
Length.UnitIdentifier.PC -> Length.Pixels.fromPicas(value)
|
||||
Length.UnitIdentifier.PT -> Length.Pixels.fromPoints(value)
|
||||
Length.UnitIdentifier.PX -> Length.Pixels(value)
|
||||
Length.UnitIdentifier.CM -> Length.Pixels.fromCentimeters(value)
|
||||
Length.UnitIdentifier.MM -> Length.Pixels.fromMillimeters(value)
|
||||
Length.UnitIdentifier.Q -> Length.Pixels.fromQuarterMillimeters(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return CompositionDimensions(x, y, width, height)
|
||||
}
|
||||
|
||||
fun lineJoin(value: String): LineJoin {
|
||||
return when (value) {
|
||||
"miter" -> LineJoin.Miter
|
||||
"bevel" -> LineJoin.Bevel
|
||||
"round" -> LineJoin.Round
|
||||
else -> LineJoin.Miter
|
||||
}
|
||||
}
|
||||
|
||||
fun lineCap(value: String): LineCap {
|
||||
return when (value) {
|
||||
"round" -> LineCap.Round
|
||||
"butt" -> LineCap.Butt
|
||||
"square" -> LineCap.Square
|
||||
else -> LineCap.Butt
|
||||
}
|
||||
}
|
||||
|
||||
fun number(value: String): Numeric {
|
||||
return when (val match = PropertyRegex.Number.regex.matchEntire(value)) {
|
||||
is MatchResult -> Numeric.Rational(match.groups[0]?.value?.toDouble() ?: 0.0)
|
||||
else -> Numeric.Rational(0.0)
|
||||
}
|
||||
}
|
||||
|
||||
fun length(value: String): Length {
|
||||
val (number, ident) = PropertyRegex.Length.regex.matchEntire(value).let {
|
||||
val number = (it?.groups as? MatchNamedGroupCollection)?.get("number")?.value?.toDouble() ?: 0.0
|
||||
val ident = Length.UnitIdentifier.valueOf(
|
||||
(it?.groups as? MatchNamedGroupCollection)?.get("ident")?.value?.uppercase() ?: "PX"
|
||||
)
|
||||
|
||||
number to ident
|
||||
}
|
||||
|
||||
return when (ident) {
|
||||
Length.UnitIdentifier.IN -> Length.Pixels.fromInches(number)
|
||||
Length.UnitIdentifier.PC -> Length.Pixels.fromPicas(number)
|
||||
Length.UnitIdentifier.PT -> Length.Pixels.fromPoints(number)
|
||||
Length.UnitIdentifier.PX -> Length.Pixels(number)
|
||||
Length.UnitIdentifier.CM -> Length.Pixels.fromCentimeters(number)
|
||||
Length.UnitIdentifier.MM -> Length.Pixels.fromMillimeters(number)
|
||||
Length.UnitIdentifier.Q -> Length.Pixels.fromQuarterMillimeters(number)
|
||||
}
|
||||
}
|
||||
|
||||
// Syntax should map to https://www.w3.org/TR/css-transforms-1/#svg-syntax
|
||||
fun transform(element: Element): Transform {
|
||||
var transform = Matrix44.IDENTITY
|
||||
|
||||
val transformValue = element.attr(Attr.TRANSFORM).let {
|
||||
it.ifEmpty {
|
||||
return Transform.None
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Number regex accepts `-` as a number lol
|
||||
val p = Pattern.compile("(matrix|translate|scale|rotate|skewX|skewY)\\([\\d\\.,\\-\\s]+\\)")
|
||||
val m = p.matcher(transformValue)
|
||||
|
||||
// TODO: This looks to be making far too many assumptions about the well-formedness of its input
|
||||
fun getTransformOperands(token: String): List<Double> {
|
||||
val number = Pattern.compile("-?[0-9.eE\\-]+")
|
||||
val nm = number.matcher(token)
|
||||
val operands = mutableListOf<Double>()
|
||||
while (nm.find()) {
|
||||
val n = nm.group().toDouble()
|
||||
operands.add(n)
|
||||
}
|
||||
return operands
|
||||
}
|
||||
while (m.find()) {
|
||||
val token = m.group()
|
||||
if (token.startsWith("matrix")) {
|
||||
val operands = getTransformOperands(token)
|
||||
val mat = Matrix44(
|
||||
operands[0], operands[2], 0.0, operands[4],
|
||||
operands[1], operands[3], 0.0, operands[5],
|
||||
0.0, 0.0, 1.0, 0.0,
|
||||
0.0, 0.0, 0.0, 1.0
|
||||
)
|
||||
transform *= mat
|
||||
}
|
||||
if (token.startsWith("scale")) {
|
||||
val operands = getTransformOperands(token.substring(5))
|
||||
val mat = Matrix44.scale(operands[0], operands.elementAtOrElse(1) { operands[0] }, 0.0)
|
||||
transform *= mat
|
||||
}
|
||||
if (token.startsWith("translate")) {
|
||||
val operands = getTransformOperands(token.substring(9))
|
||||
val mat = Matrix44.translate(operands[0], operands.elementAtOrElse(1) { 0.0 }, 0.0)
|
||||
transform *= mat
|
||||
}
|
||||
if (token.startsWith("rotate")) {
|
||||
val operands = getTransformOperands(token.substring(6))
|
||||
val angle = Math.toRadians(operands[0])
|
||||
val sina = sin(angle)
|
||||
val cosa = cos(angle)
|
||||
val x = operands.elementAtOrElse(1) { 0.0 }
|
||||
val y = operands.elementAtOrElse(2) { 0.0 }
|
||||
val mat = Matrix44(
|
||||
cosa, -sina, 0.0, -x * cosa + y * sina + x,
|
||||
sina, cosa, 0.0, -x * sina - y * cosa + y,
|
||||
0.0, 0.0, 1.0, 0.0,
|
||||
0.0, 0.0, 0.0, 1.0
|
||||
)
|
||||
transform *= mat
|
||||
}
|
||||
if (token.startsWith("skewX")) {
|
||||
val operands = getTransformOperands(token.substring(5))
|
||||
val mat = Matrix44(
|
||||
1.0, tan(Math.toRadians(operands[0])), 0.0, 0.0,
|
||||
0.0, 1.0, 0.0, 0.0,
|
||||
0.0, 0.0, 1.0, 0.0,
|
||||
0.0, 0.0, 0.0, 1.0
|
||||
)
|
||||
transform *= mat
|
||||
}
|
||||
if (token.startsWith("skewY")) {
|
||||
val operands = getTransformOperands(token.substring(5))
|
||||
val mat = Matrix44(
|
||||
1.0, 0.0, 0.0, 0.0,
|
||||
tan(Math.toRadians(operands[0])), 1.0, 0.0, 0.0,
|
||||
0.0, 0.0, 1.0, 0.0,
|
||||
0.0, 0.0, 0.0, 1.0
|
||||
)
|
||||
transform *= mat
|
||||
}
|
||||
}
|
||||
|
||||
return if (transform != Matrix44.IDENTITY) {
|
||||
Transform.Matrix(transform)
|
||||
} else {
|
||||
Transform.None
|
||||
}
|
||||
}
|
||||
|
||||
/** Assumes [numbers] consists of at least 2 elements. */
|
||||
private fun pointsToCommands(numbers: List<Double>): List<Command> {
|
||||
val commands = mutableListOf(Command("M", numbers[0], numbers[1]))
|
||||
numbers.drop(2).windowed(2, 2, false).mapTo(commands) { (x, y) ->
|
||||
Command("L", x, y)
|
||||
}
|
||||
return commands
|
||||
}
|
||||
|
||||
fun polygon(element: Element): List<Command> {
|
||||
val commands = polyline(element).toMutableList()
|
||||
commands.add(Command("Z"))
|
||||
return commands
|
||||
}
|
||||
|
||||
fun polyline(element: Element): List<Command> {
|
||||
val numbers = element.attr(Attr.POINTS)
|
||||
.trim()
|
||||
.split(PropertyRegex.commaWsp)
|
||||
.takeWhile(PropertyRegex.Number.regex::matches)
|
||||
.map(String::toDouble)
|
||||
return if (numbers.size > 1) {
|
||||
if (numbers.size and 1 == 1) {
|
||||
logger.warn { "${element.tagName()} attribute ${Attr.POINTS} has odd amount of numbers" }
|
||||
pointsToCommands(numbers.dropLast(1))
|
||||
} else {
|
||||
pointsToCommands(numbers)
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ellipsePath(x: Double, y: Double, width: Double, height: Double): List<Command> {
|
||||
val dx = x - width / 2
|
||||
val dy = y - height / 2
|
||||
|
||||
val kappa = 0.5522848
|
||||
// control point offset horizontal
|
||||
val ox = width / 2 * kappa
|
||||
// control point offset vertical
|
||||
val oy = height / 2 * kappa
|
||||
// x-end
|
||||
val xe = dx + width
|
||||
// y-end
|
||||
val ye = dy + height
|
||||
|
||||
return listOf(
|
||||
Command("M", dx, y),
|
||||
Command("C", dx, y - oy, x - ox, dy, x, dy),
|
||||
Command("C", x + ox, dy, xe, y - oy, xe, y),
|
||||
Command("C", xe, y + oy, x + ox, ye, x, ye),
|
||||
Command("C", x - ox, ye, dx, y + oy, dx, y),
|
||||
Command("z")
|
||||
)
|
||||
}
|
||||
|
||||
fun circle(element: Element): List<Command> {
|
||||
val cx = element.attr(Attr.CX).let {
|
||||
if (it.isEmpty()) 0.0 else it.toDoubleOrNull()
|
||||
} ?: return emptyList()
|
||||
val cy = element.attr(Attr.CY).let {
|
||||
if (it.isEmpty()) 0.0 else it.toDoubleOrNull()
|
||||
} ?: return emptyList()
|
||||
val r = element.attr(Attr.R).let {
|
||||
if (it.isEmpty()) 0.0 else it.toDoubleOrNull()?.times(2.0)
|
||||
} ?: return emptyList()
|
||||
|
||||
return ellipsePath(cx, cy, r, r)
|
||||
}
|
||||
|
||||
fun ellipse(element: Element): List<Command> {
|
||||
val cx = element.attr(Attr.CX).let {
|
||||
if (it.isEmpty()) 0.0 else it.toDoubleOrNull()
|
||||
} ?: return emptyList()
|
||||
val cy = element.attr(Attr.CY).let {
|
||||
if (it.isEmpty()) 0.0 else it.toDoubleOrNull()
|
||||
} ?: return emptyList()
|
||||
val rx = element.attr(Attr.RX).let {
|
||||
if (it.isEmpty()) 0.0 else it.toDoubleOrNull()?.times(2.0)
|
||||
} ?: return emptyList()
|
||||
val ry = element.attr(Attr.RY).let {
|
||||
if (it.isEmpty()) 0.0 else it.toDoubleOrNull()?.times(2.0)
|
||||
} ?: return emptyList()
|
||||
|
||||
return ellipsePath(cx, cy, rx, ry)
|
||||
}
|
||||
|
||||
fun rectangle(element: Element): List<Command> {
|
||||
val x = element.attr(Attr.X).let { if (it.isEmpty()) 0.0 else it.toDoubleOrNull() } ?: return emptyList()
|
||||
val y = element.attr(Attr.Y).let { if (it.isEmpty()) 0.0 else it.toDoubleOrNull() } ?: return emptyList()
|
||||
val width = element.attr(Attr.WIDTH).toDoubleOrNull() ?: return emptyList()
|
||||
val height = element.attr(Attr.HEIGHT).toDoubleOrNull() ?: return emptyList()
|
||||
|
||||
return listOf(
|
||||
Command("M", x, y),
|
||||
Command("h", width),
|
||||
Command("v", height),
|
||||
Command("h", -width),
|
||||
Command("z")
|
||||
)
|
||||
}
|
||||
|
||||
fun line(element: Element): List<Command> {
|
||||
val x1 = element.attr(Attr.X1).toDoubleOrNull() ?: return emptyList()
|
||||
val x2 = element.attr(Attr.X2).toDoubleOrNull() ?: return emptyList()
|
||||
val y1 = element.attr(Attr.Y1).toDoubleOrNull() ?: return emptyList()
|
||||
val y2 = element.attr(Attr.Y2).toDoubleOrNull() ?: return emptyList()
|
||||
|
||||
return listOf(
|
||||
Command("M", x1, y1),
|
||||
Command("L", x2, y2)
|
||||
)
|
||||
}
|
||||
|
||||
fun path(element: Element): List<Command> {
|
||||
val pathValue = element.attr(Attr.D)
|
||||
|
||||
if (pathValue.trim() == "none") {
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
val rawCommands = pathValue.split("(?=[MmZzLlHhVvCcSsQqTtAa])".toRegex()).map(String::trim)
|
||||
val numbers = Pattern.compile("[-+]?[0-9]*[.]?[0-9]+(?:[eE][-+]?[0-9]+)?")
|
||||
val commands = mutableListOf<Command>()
|
||||
|
||||
for (rawCommand in rawCommands) {
|
||||
if (rawCommand.isNotEmpty()) {
|
||||
val numberMatcher = numbers.matcher(rawCommand)
|
||||
val operands = mutableListOf<Double>()
|
||||
while (numberMatcher.find()) {
|
||||
operands.add(numberMatcher.group().toDouble())
|
||||
}
|
||||
commands += Command(rawCommand[0].toString(), *(operands.toDoubleArray()))
|
||||
}
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
|
||||
fun color(colorValue: String): Paint {
|
||||
val col = colorValue.lowercase()
|
||||
|
||||
return when {
|
||||
col.isEmpty() -> Paint.None
|
||||
col.startsWith("#") -> {
|
||||
val normalizedColor = normalizeColorHex(col) ?: return Paint.None
|
||||
val v = normalizedColor.toLong(radix = 16)
|
||||
val vi = v.toInt()
|
||||
val r = vi shr 16 and 0xff
|
||||
val g = vi shr 8 and 0xff
|
||||
val b = vi and 0xff
|
||||
Paint.RGB(ColorRGBa(r / 255.0, g / 255.0, b / 255.0, linearity = Linearity.SRGB))
|
||||
}
|
||||
col.startsWith("rgb(") -> rgbFunction(col)
|
||||
col in cssColorNames -> Paint.RGB(ColorRGBa.fromHex(cssColorNames[col]!!))
|
||||
else -> Paint.None
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeColorHex(colorHex: String): String? {
|
||||
val matchResult = PropertyRegex.RGBHex.regex.matchEntire(colorHex) ?: return null
|
||||
|
||||
val hexValue = matchResult.groups[1]!!.value.lowercase()
|
||||
val normalizedArgb = when (hexValue.length) {
|
||||
3 -> expandToTwoDigitsPerComponent("f$hexValue")
|
||||
6 -> hexValue
|
||||
else -> return null
|
||||
}
|
||||
|
||||
return normalizedArgb
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses rgb functional notation as described in CSS2 spec
|
||||
*/
|
||||
private fun rgbFunction(rgbValue: String): Paint {
|
||||
|
||||
val result =
|
||||
PropertyRegex.RGBFunctional.regex.matchEntire(rgbValue) ?: return Paint.None
|
||||
|
||||
// The first three capture groups contain values if the match was without percentages
|
||||
// Otherwise the values are in capture groups #4 to #6.
|
||||
// Based on this information, we can deduce the divisor.
|
||||
val divisor = if (result.groups[1] == null) {
|
||||
100.0
|
||||
} else {
|
||||
255.0
|
||||
}
|
||||
|
||||
// Drop full match, filter out empty matches, map it, deconstruct it
|
||||
val (r, g, b) = result.groupValues
|
||||
.drop(1)
|
||||
.filter(String::isNotBlank)
|
||||
.map { it.toDouble().coerceIn(0.0..divisor) / divisor }
|
||||
return Paint.RGB(ColorRGBa(r, g, b, linearity = Linearity.SRGB))
|
||||
}
|
||||
|
||||
private fun expandToTwoDigitsPerComponent(hexValue: String) =
|
||||
hexValue.asSequence()
|
||||
.map { "$it$it" }
|
||||
.reduce(String::plus)
|
||||
}
|
||||
151
orx-svg/src/jvmMain/kotlin/SVGWriter.kt
Normal file
151
orx-svg/src/jvmMain/kotlin/SVGWriter.kt
Normal file
@@ -0,0 +1,151 @@
|
||||
package org.openrndr.extra.svg
|
||||
|
||||
import org.jsoup.nodes.*
|
||||
import org.openrndr.extra.composition.*
|
||||
import org.openrndr.extra.composition.TextNode
|
||||
|
||||
import java.io.*
|
||||
|
||||
fun Composition.saveToFile(file: File) {
|
||||
if (file.extension == "svg") {
|
||||
val svg = writeSVG(this)
|
||||
file.writeText(svg)
|
||||
} else {
|
||||
throw IllegalArgumentException("can only write svg files, the extension '${file.extension}' is not supported")
|
||||
}
|
||||
}
|
||||
|
||||
fun Composition.toSVG() = writeSVG(this)
|
||||
|
||||
private val CompositionNode.svgId: String
|
||||
get() = when (val tempId = id) {
|
||||
"" -> ""
|
||||
null -> ""
|
||||
else -> "id=\"$tempId\""
|
||||
}
|
||||
|
||||
private val CompositionNode.svgAttributes: String
|
||||
get() {
|
||||
return attributes.map {
|
||||
if (it.value != null && it.value != "") {
|
||||
"${it.key}=\"${Entities.escape(it.value ?: "")}\""
|
||||
} else {
|
||||
it.key
|
||||
}
|
||||
}.joinToString(" ")
|
||||
}
|
||||
|
||||
private fun Styleable.serialize(parentStyleable: Styleable? = null): String {
|
||||
val sb = StringBuilder()
|
||||
|
||||
val filtered = this.properties.filter {
|
||||
it.key != AttributeOrPropertyKey.SHADESTYLE
|
||||
}
|
||||
// Inheritance can't be checked without a parentStyleable
|
||||
when (parentStyleable) {
|
||||
null -> filtered.forEach { (t, u) ->
|
||||
if (u.toString().isNotEmpty()) {
|
||||
sb.append("$t=\"${u.toString()}\" ")
|
||||
}
|
||||
}
|
||||
else -> filtered.forEach { (t, u) ->
|
||||
if (u.toString().isNotEmpty() && !this.isInherited(parentStyleable, t)) {
|
||||
sb.append("$t=\"${u.toString()}\" ")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return sb.trim().toString()
|
||||
}
|
||||
|
||||
fun writeSVG(
|
||||
composition: Composition,
|
||||
topLevelId: String = "openrndr-svg"
|
||||
): String {
|
||||
val sb = StringBuilder()
|
||||
sb.append("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n")
|
||||
|
||||
val defaultNamespaces = mapOf(
|
||||
"xmlns" to "http://www.w3.org/2000/svg",
|
||||
"xmlns:xlink" to "http://www.w3.org/1999/xlink"
|
||||
)
|
||||
|
||||
val namespaces = (defaultNamespaces + composition.namespaces).map { (k, v) ->
|
||||
"$k=\"$v\""
|
||||
}.joinToString(" ")
|
||||
|
||||
val styleSer = composition.style.serialize()
|
||||
val docStyleSer = composition.documentStyle.serialize()
|
||||
|
||||
sb.append("<svg version=\"1.2\" baseProfile=\"tiny\" id=\"$topLevelId\" $namespaces $styleSer $docStyleSer>")
|
||||
|
||||
var textPathID = 0
|
||||
process(composition.root) { stage ->
|
||||
if (stage == VisitStage.PRE) {
|
||||
|
||||
val styleSerialized = this.style.serialize(this.parent?.style)
|
||||
|
||||
when (this) {
|
||||
is GroupNode -> {
|
||||
val attributes = listOf(svgId, styleSerialized, svgAttributes)
|
||||
.filter(String::isNotEmpty)
|
||||
.joinToString(" ")
|
||||
sb.append("<g${" $attributes"}>\n")
|
||||
}
|
||||
is ShapeNode -> {
|
||||
val pathAttribute = "d=\"${shape.toSvg()}\""
|
||||
|
||||
val attributes = listOf(
|
||||
svgId,
|
||||
styleSerialized,
|
||||
svgAttributes,
|
||||
pathAttribute
|
||||
)
|
||||
.filter(String::isNotEmpty)
|
||||
.joinToString(" ")
|
||||
|
||||
sb.append("<path $attributes/>\n")
|
||||
}
|
||||
|
||||
is TextNode -> {
|
||||
val contour = this.contour
|
||||
val escapedText = Entities.escape(this.text)
|
||||
if (contour == null) {
|
||||
sb.append("<text $svgId $svgAttributes>$escapedText</text>")
|
||||
} else {
|
||||
sb.append("<defs>")
|
||||
sb.append("<path id=\"text$textPathID\" d=\"${contour.toSvg()}\"/>")
|
||||
sb.append("</defs>")
|
||||
sb.append("<text $styleSerialized><textPath href=\"#text$textPathID\">$escapedText</textPath></text>")
|
||||
textPathID++
|
||||
}
|
||||
}
|
||||
is ImageNode -> {
|
||||
val dataUrl = this.image.toDataUrl()
|
||||
sb.append("""<image xlink:href="$dataUrl" height="${this.image.height}" width="${this.image.width}"/>""")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (this is GroupNode) {
|
||||
sb.append("</g>\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.append("</svg>")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
|
||||
private enum class VisitStage {
|
||||
PRE,
|
||||
POST
|
||||
}
|
||||
|
||||
private fun process(compositionNode: CompositionNode, visitor: CompositionNode.(stage: VisitStage) -> Unit) {
|
||||
compositionNode.visitor(VisitStage.PRE)
|
||||
if (compositionNode is GroupNode) {
|
||||
compositionNode.children.forEach { process(it, visitor) }
|
||||
}
|
||||
compositionNode.visitor(VisitStage.POST)
|
||||
}
|
||||
Reference in New Issue
Block a user