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

commonMain.io.nacular.doodle.drawing.impl.VectorRendererSvg.kt Maven / Gradle / Ivy

There is a newer version: 0.10.2
Show newest version
package io.nacular.doodle.drawing.impl

import io.nacular.doodle.dom.BoundingBoxOptions
import io.nacular.doodle.dom.HTMLElement
import io.nacular.doodle.dom.HtmlFactory
import io.nacular.doodle.dom.Node
import io.nacular.doodle.dom.SVGCircleElement
import io.nacular.doodle.dom.SVGElement
import io.nacular.doodle.dom.SVGEllipseElement
import io.nacular.doodle.dom.SVGGraphicsElement
import io.nacular.doodle.dom.SVGLinearGradientElement
import io.nacular.doodle.dom.SVGPathElement
import io.nacular.doodle.dom.SVGPatternElement
import io.nacular.doodle.dom.SVGPolygonElement
import io.nacular.doodle.dom.SVGRadialGradientElement
import io.nacular.doodle.dom.SVGRectElement
import io.nacular.doodle.dom.SVGTSpanElement
import io.nacular.doodle.dom.SVGTextElement
import io.nacular.doodle.dom.SvgFactory
import io.nacular.doodle.dom.add
import io.nacular.doodle.dom.addIfNotPresent
import io.nacular.doodle.dom.childAt
import io.nacular.doodle.dom.clear
import io.nacular.doodle.dom.clipPath
import io.nacular.doodle.dom.defaultFontSize
import io.nacular.doodle.dom.get
import io.nacular.doodle.dom.getBBox_
import io.nacular.doodle.dom.parent
import io.nacular.doodle.dom.remove
import io.nacular.doodle.dom.removeTransform
import io.nacular.doodle.dom.rgbaString
import io.nacular.doodle.dom.setBorderRadius
import io.nacular.doodle.dom.setBounds
import io.nacular.doodle.dom.setCircle
import io.nacular.doodle.dom.setClipPath
import io.nacular.doodle.dom.setDefaultFill
import io.nacular.doodle.dom.setEllipse
import io.nacular.doodle.dom.setEnd
import io.nacular.doodle.dom.setFill
import io.nacular.doodle.dom.setFillPattern
import io.nacular.doodle.dom.setFillRule
import io.nacular.doodle.dom.setFloodColor
import io.nacular.doodle.dom.setFont
import io.nacular.doodle.dom.setGradientUnits
import io.nacular.doodle.dom.setHeight
import io.nacular.doodle.dom.setHeightPercent
import io.nacular.doodle.dom.setId
import io.nacular.doodle.dom.setOpacity
import io.nacular.doodle.dom.setPathData
import io.nacular.doodle.dom.setPatternTransform
import io.nacular.doodle.dom.setPoints
import io.nacular.doodle.dom.setPosition
import io.nacular.doodle.dom.setRX
import io.nacular.doodle.dom.setRY
import io.nacular.doodle.dom.setRadius
import io.nacular.doodle.dom.setStart
import io.nacular.doodle.dom.setStopColor
import io.nacular.doodle.dom.setStopOffset
import io.nacular.doodle.dom.setStroke
import io.nacular.doodle.dom.setStrokeColor
import io.nacular.doodle.dom.setStrokePattern
import io.nacular.doodle.dom.setTextDecoration
import io.nacular.doodle.dom.setTextSpacing
import io.nacular.doodle.dom.setTransform
import io.nacular.doodle.dom.setWidth
import io.nacular.doodle.dom.setWidthPercent
import io.nacular.doodle.dom.setX
import io.nacular.doodle.dom.setX1
import io.nacular.doodle.dom.setX2
import io.nacular.doodle.dom.setY
import io.nacular.doodle.dom.setY1
import io.nacular.doodle.dom.setY2
import io.nacular.doodle.drawing.AffineTransform
import io.nacular.doodle.drawing.Color.Companion.White
import io.nacular.doodle.drawing.ColorPaint
import io.nacular.doodle.drawing.Font
import io.nacular.doodle.drawing.GradientPaint
import io.nacular.doodle.drawing.ImagePaint
import io.nacular.doodle.drawing.InnerShadow
import io.nacular.doodle.drawing.LinearGradientPaint
import io.nacular.doodle.drawing.OuterShadow
import io.nacular.doodle.drawing.Paint
import io.nacular.doodle.drawing.PatternPaint
import io.nacular.doodle.drawing.RadialGradientPaint
import io.nacular.doodle.drawing.Renderer.FillRule
import io.nacular.doodle.drawing.Shadow
import io.nacular.doodle.drawing.Stroke
import io.nacular.doodle.drawing.SweepGradientPaint
import io.nacular.doodle.drawing.TextMetrics
import io.nacular.doodle.geometry.Circle
import io.nacular.doodle.geometry.Ellipse
import io.nacular.doodle.geometry.Point
import io.nacular.doodle.geometry.Point.Companion.Origin
import io.nacular.doodle.geometry.Polygon
import io.nacular.doodle.geometry.Rectangle
import io.nacular.doodle.geometry.Size
import io.nacular.doodle.geometry.toPath
import io.nacular.doodle.image.Image
import io.nacular.doodle.image.impl.ImageImpl
import io.nacular.doodle.text.Style
import io.nacular.doodle.text.StyledText
import io.nacular.doodle.text.TextSpacing
import io.nacular.doodle.utils.IdGenerator
import io.nacular.doodle.utils.TextAlignment
import io.nacular.doodle.utils.TextAlignment.Center
import io.nacular.doodle.utils.TextAlignment.End
import io.nacular.doodle.utils.TextAlignment.Justify
import io.nacular.doodle.utils.TextAlignment.Start
import io.nacular.doodle.utils.splitMatches
import io.nacular.measured.units.Angle
import io.nacular.measured.units.Angle.Companion.cos
import io.nacular.measured.units.Angle.Companion.degrees
import io.nacular.measured.units.Angle.Companion.sin
import io.nacular.measured.units.Measure
import io.nacular.measured.units.times
import kotlin.math.abs
import kotlin.math.min

internal open class VectorRendererSvg(
    protected var context       : CanvasContext,
    private   val svgFactory    : SvgFactory,
    private   val htmlFactory   : HtmlFactory,
    private   val aligner       : TextVerticalAligner,
    private   val textMetrics   : TextMetrics,
    private   val idGenerator   : IdGenerator,
                  rootSvgElement: SVGElement? = null): VectorRenderer {

    private inner class PatternCanvas(
        context       : CanvasContext,
        svgFactory    : SvgFactory,
        htmlFactory   : HtmlFactory,
        aligner       : TextVerticalAligner,
        idGenerator   : IdGenerator,
        patternElement: SVGElement
    ): VectorRendererSvg(context, svgFactory, htmlFactory, aligner, textMetrics, idGenerator, patternElement), io.nacular.doodle.drawing.PatternCanvas {
        private val contextWrapper = ContextWrapper(context)

        init {
            this.context = contextWrapper
        }

        override var size get() = context.size; set(@Suppress("UNUSED_PARAMETER") value) {}

        override fun transform(transform: AffineTransform, block: io.nacular.doodle.drawing.PatternCanvas.() -> Unit) = when {
            transform.isIdentity -> block(this)
            else                 -> {
                pushGroup()

                svgElement?.setTransform(transform)

                block(this)

                popGroup()
            }
        }

        override fun image(image: Image, destination: Rectangle, opacity: Float, radius: Double, source: Rectangle) {
            if (image is ImageImpl && opacity > 0 && !(source.empty || destination.empty)) {
                updateRootSvg()

                if (source.size == image.size && source.position == Origin) {
                    completeOperation(createImage(image, destination, radius, opacity))
                } else {
                    val xRatio = destination.width  / source.width
                    val yRatio = destination.height / source.height

                    val imageElement = createImage(image,
                        Rectangle(0 - xRatio * source.x,
                            0 - yRatio * source.y,
                            xRatio * image.size.width,
                            yRatio * image.size.height),
                        0.0,
                        opacity)

                    createClip(destination, radius).let {
                        completeOperation(it)
                        imageElement.style.clipPath = "url(#${it.id})"
                    }

                    completeOperation(imageElement)
                }
            }
        }

        override fun clip(rectangle: Rectangle, radius: Double, block: io.nacular.doodle.drawing.PatternCanvas.() -> Unit) {
            pushClip(rectangle.toPath(radius))
            block   (this                    )
            popClip (                        )
        }

        override fun clip(polygon: Polygon, block: io.nacular.doodle.drawing.PatternCanvas.() -> Unit) {
            pushClip(polygon.toPath())
            block   (this            )
            popClip (                )
        }

        override fun clip(ellipse: Ellipse, block: io.nacular.doodle.drawing.PatternCanvas.() -> Unit) {
            pushClip(ellipse.toPath())
            block   (this            )
            popClip (                )
        }

        override fun clip(path: io.nacular.doodle.geometry.Path, block: io.nacular.doodle.drawing.PatternCanvas.() -> Unit) {
            pushClip(path)
            block   (this)
            popClip (    )
        }

        override fun shadow(shadow: Shadow, block: io.nacular.doodle.drawing.PatternCanvas.() -> Unit) {
            contextWrapper.shadows += shadow
            block(this)
            contextWrapper.shadows -= shadow
        }

        private fun createClip(rectangle: Rectangle, radius: Double = 0.0) = createOrUse("clipPath").apply {
            if (id.isBlank()) { setId(nextId()) }

            addIfNotPresent(makeRect(rectangle, radius) ,0)
        }
    }

    protected var svgElement    : SVGElement? = null
    private   var rootSvgElement: SVGElement? = null

    private val region get() = context.renderRegion

    private var renderPosition: Node? = null

    init {
        rootSvgElement?.let {
            svgElement          = it
            this.rootSvgElement = svgElement
            renderPosition      = svgElement?.firstChild
        }
    }

    override fun line(start: Point, end: Point, stroke: Stroke) = drawPath(stroke, null, null, start, end)

    override fun path(points: List,                 fill: Paint, fillRule: FillRule?) = drawPath(null,   fill, fillRule, *points.toTypedArray())
    override fun path(points: List, stroke: Stroke                                  ) = drawPath(stroke, null, null,     *points.toTypedArray())
    override fun path(points: List, stroke: Stroke, fill: Paint, fillRule: FillRule?) = drawPath(stroke, fill, fillRule, *points.toTypedArray())

    override fun path(path: io.nacular.doodle.geometry.Path,                 fill: Paint, fillRule: FillRule?) = drawPath(path.data, null,   fill, fillRule)
    override fun path(path: io.nacular.doodle.geometry.Path, stroke: Stroke                                  ) = drawPath(path.data, stroke, null, null    )
    override fun path(path: io.nacular.doodle.geometry.Path, stroke: Stroke, fill: Paint, fillRule: FillRule?) = drawPath(path.data, stroke, fill, fillRule)

    override fun rect(rectangle: Rectangle,                 fill: Paint ) = drawRect(rectangle, null,   fill)
    override fun rect(rectangle: Rectangle, stroke: Stroke, fill: Paint?) = drawRect(rectangle, stroke, fill)

    override fun poly(polygon: Polygon,                 fill: Paint ) = drawPoly(polygon, null,   fill)
    override fun poly(polygon: Polygon, stroke: Stroke, fill: Paint?) = drawPoly(polygon, stroke, fill)

    override fun rect(rectangle: Rectangle, radius: Double,                 fill: Paint ) = drawRect(rectangle, radius, null,   fill)
    override fun rect(rectangle: Rectangle, radius: Double, stroke: Stroke, fill: Paint?) = drawRect(rectangle, radius, stroke, fill)

    override fun arc(center: Point, radius: Double, sweep: Measure, rotation: Measure,                 fill: Paint ) = drawArc(center, radius, sweep, rotation, null,   fill)
    override fun arc(center: Point, radius: Double, sweep: Measure, rotation: Measure, stroke: Stroke, fill: Paint?) = drawArc(center, radius, sweep, rotation, stroke, fill)

    override fun wedge(center: Point, radius: Double, sweep: Measure, rotation: Measure,                 fill: Paint ) = drawWedge(center, radius, sweep, rotation, null,   fill)
    override fun wedge(center: Point, radius: Double, sweep: Measure, rotation: Measure, stroke: Stroke, fill: Paint?) = drawWedge(center, radius, sweep, rotation, stroke, fill)

    override fun circle(circle: Circle,                 fill: Paint ) = drawCircle(circle, null,   fill)
    override fun circle(circle: Circle, stroke: Stroke, fill: Paint?) = drawCircle(circle, stroke, fill)

    override fun ellipse(ellipse: Ellipse,                 fill: Paint ) = drawEllipse(ellipse, null,   fill)
    override fun ellipse(ellipse: Ellipse, stroke: Stroke, fill: Paint?) = drawEllipse(ellipse, stroke, fill)

    override fun text(text: String, font: Font?, at: Point, fill: Paint, textSpacing: TextSpacing) {
        var textElement: SVGTextElement? = null

        present(stroke = null, fill = fill) {
            when {
                text.isNotBlank() -> makeText(text, font, at, fill, textSpacing).also { textElement = it }
                else              -> null
            }
        }

        textElement?.let { adjustTextAfterDisplay(it, aligner.verticalOffset(text, font)) }
    }

    override fun text(text: StyledText, at: Point, textSpacing: TextSpacing) {
        textInternal(text, at, textSpacing, aligner.verticalOffset(text.text, text.maxFont))
    }

    private fun textInternal(text: StyledText, at: Point, textSpacing: TextSpacing, yOffset: Double) {
        when {
            text.count > 0 -> {
                syncShadows  ()
                updateRootSvg() // Done here since present normally does this
                val textElement = makeStyledText(text, at, textSpacing)
                completeOperation(textElement)
                adjustTextAfterDisplay(textElement, yOffset)
            }
        }
    }

    private val StyledText.maxFont    : Font? get() = filter { it.first.isNotBlank() }.mapNotNull  { it.second.font }.maxByOrNull { it.size }
    private val StyledText.maxFontSize: Int   get() = maxFont?.size ?: defaultFontSize

    private fun adjustTextAfterDisplay(textElement: SVGTextElement, yOffset: Double) {
        // shift text down since no other way to get baseline alignment to work
        textElement.setY(
            (textElement.getAttribute("y")?.toDouble() ?: 0.0) + yOffset
        )
    }

    override fun wrapped(text: String, at: Point, width: Double, fill: Paint, font: Font?, indent: Double, alignment: TextAlignment, lineSpacing: Float, textSpacing: TextSpacing) {
        syncShadows()

        wrappedText(StyledText(text, font, foreground = fill), at, indent, width, alignment, lineSpacing, textSpacing)
    }

    override fun wrapped(text: StyledText, at: Point, width: Double, indent: Double, alignment: TextAlignment, lineSpacing: Float, textSpacing: TextSpacing) {
        syncShadows()

        wrappedText(text, at, indent, width, alignment, lineSpacing, textSpacing)
    }

    private var shadows = mutableListOf()

    private fun syncShadows() {
        while (shadows.size < context.shadows.size) {
            add(context.shadows[shadows.size])
        }

        while (shadows.size > context.shadows.size) {
            shadows.lastOrNull()?.let { remove(it) }
        }
    }

    protected fun add(shadow: Shadow) {
        shadows.plusAssign(shadow)

        pushSvg()

        when (shadow) {
            is InnerShadow -> innerShadow(shadow)
            is OuterShadow -> outerShadow(shadow)
        }.let {
            completeOperation(it)
            svgElement?.style?.filter = "url(#${it.id})"
        }
    }

    protected fun remove(shadow: Shadow) {
        shadows.minusAssign(shadow)

        popClip()
    }

    override fun clear() {
        val renderPosition = context.renderPosition

        this.renderPosition = null
        rootSvgElement      = null
        svgElement          = null
        shadows.clear()

        // HACK: to avoid interpreting the contents of an 'opaque' element
        if (renderPosition != null && !context.isRawData) {
            findSvgDepthFirst(context.renderRegion)?.let {
                rootSvgElement      = it
                svgElement          = it
                this.renderPosition = it.firstChild
            }
        }
    }

    private var internalFlush = false

    private fun internalFlush() {
        internalFlush = true
        flush()
        internalFlush = false
    }

    override fun flush() {
        if (!internalFlush) {
            finalizeShadows()
        }

        var element = renderPosition

        while (element != null) {
            val next = element.nextSibling

            element.parent?.remove(element)

            element = next
        }

        renderPosition = null
    }

    protected fun nextId() = idGenerator.nextId()

    protected fun makeRect(rectangle: Rectangle, radius: Double = 0.0): SVGRectElement = createOrUse("rect").apply {
        setBounds(rectangle)

        setRadius(radius)
        setFill  (null  )
        setStroke(null  )
    }

    protected fun pushClip(path: io.nacular.doodle.geometry.Path) {
        pushSvg {
            style.setClipPath(path)

            renderPosition = renderPosition?.nextSibling
        }
    }

    protected fun pushSvg(block: SVGElement.() -> Unit = {}) {
        val svg = createOrUse("svg").apply {
            renderPosition = this.firstChild

            block(this)
        }

        if (svgElement == null || svg.parentNode != svgElement) {
            updateRootSvg()

            completeOperation(svg)
        }

        svgElement = svg
    }

    protected fun popClip() {
        // Clear any remaining items that were previously rendered within the sub-region that won't be rendered anymore
        internalFlush()

        renderPosition = svgElement?.nextSibling

        svgElement?.parentNode?.let {
            svgElement = it as SVGElement
        }
    }

    protected fun pushGroup() {
        createOrUse("g").apply {
            renderPosition = this.firstChild
        }.also {
            completeOperation(it)
            svgElement = it
        }
    }

    protected fun popGroup() {
        popClip()
        //renderPosition = renderPosition?.parent?.nextSibling
    }

    protected fun updateRootSvg() {
        if (rootSvgElement == null ||
            (context.renderPosition !== rootSvgElement /*&&
//                    (rootSvgLastChild() ||
                     context.renderPosition !== rootSvgElement?.nextSibling*/)) {
            // Initialize new SVG root if
            // 1) not initialized
            // 2) it is not longer the active element
            svgElement     = createOrUse("svg", context.renderPosition)
            rootSvgElement = svgElement
            renderPosition = svgElement?.firstChild
        }
    }

    protected open fun completeOperation(element: SVGElement) {
        if (context.renderPosition == null && svgElement?.parent == null) {
            region.add(svgElement!!)
            context.renderPosition = rootSvgElement
        } else if (context.renderPosition !== rootSvgElement) {
            context.renderPosition?.parent?.replaceChild(rootSvgElement!!, context.renderPosition!!)
            context.renderPosition = rootSvgElement
        }

        if (renderPosition == null) {
            svgElement?.add(element)
        } else {
            if (renderPosition !== element) {
                renderPosition?.parent?.replaceChild(element, renderPosition!!)
            }

            renderPosition = element.nextSibling
        }

        context.markDirty()
    }

    protected fun  createOrUse(tag: String, possible: Node? = renderPosition): T {
        val element: Node? = possible

        return when {
            element == null || element.nodeName != tag -> svgFactory(tag)
            element is SVGElement                      -> {
                if (tag !in containerElements) { element.clear() }
                element.style.filter         = ""
                element.style.textDecoration = ""
                element.removeTransform()
                @Suppress("UNCHECKED_CAST")
                element as T
            }
            else -> throw Exception("Error") // FIXME: handle better
        }
    }

    private fun makeText(text: String, font: Font?, at: Point, fill: Paint?, textSpacing: TextSpacing) = createOrUse("text").apply {
        if (textContent != text) {
            textContent = text
        }

        setPosition(at)

        this.style.whiteSpace = "pre"
        this.style.setTextSpacing(textSpacing)

        font?.let {
            style.setFont(it)
        }

        when (fill) {
            null -> setDefaultFill(    )
            else -> setFill       (null)
        }

        setStroke(null)
    }

    private fun makeStyledText(text: StyledText, at: Point, textSpacing: TextSpacing) = createOrUse("text").apply {
        setPosition(at)

        text.forEach { (text, style) ->
            val background: SVGElement? = (style.background?.takeIf { it is ColorPaint } as? ColorPaint?)?.let { textBackground(it) }?.also {
                completeOperation(it)
            }

            add(makeTextSegment(text, style, textSpacing).also { segment ->
                background?.let {
                    segment.style.filter = "url(#${it.id})"
                }
            })
        }
    }

    private fun makeTextSegment(text: String, style: Style, textSpacing: TextSpacing) = createOrUse("tspan").apply {
        if (textContent != text) {
            textContent = text
        }

        setFill  (null)
        setStroke(null)

        this.style.whiteSpace = "pre"
        this.style.setTextSpacing(textSpacing)

        style.font?.let {
            this.style.setFont(it)
        }

        this.style.setTextDecoration(style.decoration)

        style.foreground?.let {
            fillElement(this, it, true)
        } ?: setDefaultFill()
    }

    private data class LineInfo(val text: StyledText, val position: Point, val wordSpacing: Double)

    private fun wrappedText(
        text       : StyledText,
        at         : Point,
        indent     : Double,
        width      : Double,
        alignment  : TextAlignment,
        lineSpacing: Float,
        textSpacing: TextSpacing
    ): Point {
        val lines              = mutableListOf()
        val (words, remaining) = text.text.splitMatches("""\s""".toRegex()).run { matches to remaining }
        var line               = StyledText("")
        var lineTest           : StyledText
        var currentPoint       = at + Point(x = indent)
        var endX               = currentPoint.x
        var currentLineWidth   = 0.0
        var oldLineWidth       = 0.0
        var numWords           = 0

        val calcStartX = { isLast: Boolean ->
            var wordSpacing = 0.0

            when (alignment) {
                Start   -> currentPoint.x
                Center  -> currentPoint.x + (width - currentLineWidth) / 2
                End     -> at.x + width - currentLineWidth
                Justify -> currentPoint.x.also {
                    if (!isLast && numWords > 1) {
                        wordSpacing = (width - (currentPoint.x - at.x) - oldLineWidth) / (numWords - 1)
                    }
                }
            } to wordSpacing
        }

        var offsetY     = 0.0
        var maxFontSize = 0

        val handleWord = { delimiter: StyledText, word: StyledText ->
            lineTest      = line.copy() + delimiter.copy() + word.copy()
            val lineWidth = textMetrics.width(lineTest, textSpacing)

            endX = currentPoint.x + lineWidth

            if (endX > at.x + width) {
                // ignore whitespace beyond the line break
                if (word.isNotBlank()) {
                    val (startX, wordSpacing) = calcStartX(false)

                    lines += LineInfo(line, Point(startX, currentPoint.y), wordSpacing)
                    line   = word.copy()

                    if (numWords > 0) {
                        maxFontSize = line.maxFontSize
                        offsetY += lineSpacing * maxFontSize
                    }

                    currentPoint = Point(at.x, at.y + offsetY)
                    endX = startX + currentLineWidth
                    numWords = 1
                }
            } else {
                ++numWords
                line              = lineTest
                currentLineWidth  = lineWidth

                val newMaxFontSize = word.maxFontSize

                // account for case where font grows as line progresses
                if (maxFontSize in 1..
            val word        = text.subString(startCharIndex until startCharIndex + chunk.match.length)
            startCharIndex += chunk.match.length

            handleWord(previousDelimiter, word)

            previousDelimiter = text.subString(startCharIndex until startCharIndex + chunk.delimiter.length)
            startCharIndex += chunk.delimiter.length
        }

        handleWord(previousDelimiter, text.subString(startCharIndex until startCharIndex + remaining.length))

        if (line.isNotBlank()) {
            val (startX, wordSpacing) = calcStartX(true)
            val lineWidth = textMetrics.width(line, textSpacing)
            endX          = startX + lineWidth

            lines += LineInfo(line, Point(startX, currentPoint.y), wordSpacing)
        }

        val verticalOffset = lines.firstOrNull { it.text.isNotBlank() }?.text?.let { l ->
            aligner.verticalOffset(l.text, l.maxFont, lineSpacing)
        } ?: 0.0

        lines.filter { it.text.isNotBlank() }.forEach { (text, at, wordSpacing) ->
            textInternal(
                text,
                at,
                TextSpacing(
                    letterSpacing = textSpacing.letterSpacing,
                    wordSpacing = wordSpacing + textSpacing.wordSpacing
                ),
                verticalOffset
            )
        }

        return Point(endX, currentPoint.y)
    }

    private fun drawPath(stroke: Stroke?, fill: Paint? = null, fillRule: FillRule? = null, vararg points: Point) = present(stroke, fill) {
        when {
            points.isNotEmpty() -> makePath(*points).also { it.setFillRule(fillRule) }
            else                -> null
        }
    }

    private fun drawPath(data: String, stroke: Stroke?, fill: Paint?, fillRule: FillRule?) = present(stroke, fill ) {
        makePath(data).also { it.setFillRule(fillRule) }
    }

    private fun present(stroke: Stroke?, fill: Paint?, block: () -> SVGGraphicsElement?) {
        syncShadows()

        if (visible(stroke, fill)) {
            // Update SVG Element to enable re-use if the top-level cursor has moved to a new place
            updateRootSvg()

            block()?.let {
                // make sure element is in dom first since some fills add new elements to the dom
                // this means we get better re-use of nodes since the order in the dom is the element creation order
                completeOperation(it)

                if (fill != null) {
                    fillElement(it, fill, stroke == null || !stroke.visible)
                }
                if (stroke != null) {
                    outlineElement(it, stroke, fill == null || !fill.visible)
                }
            }
        }
    }

    private fun drawRect(rectangle: Rectangle, stroke: Stroke?, fill: Paint?) = present(stroke, fill) {
        when {
            !rectangle.empty -> makeClosedPath(
                    Point(rectangle.x,                   rectangle.y                   ),
                    Point(rectangle.x + rectangle.width, rectangle.y                   ),
                    Point(rectangle.x + rectangle.width, rectangle.y + rectangle.height),
                    Point(rectangle.x,                   rectangle.y + rectangle.height))
            else -> null
        }
    }

    private fun drawRect(rectangle: Rectangle, radius: Double, stroke: Stroke?, fill: Paint?) = present(stroke, fill) {
        when {
            !rectangle.empty -> makeRoundedRect(rectangle, radius)
            else             -> null
        }
    }

    private fun visible(stroke: Stroke?, fill: Paint?) = (stroke?.visible ?: false) || (fill?.visible ?: false)

    private fun drawPoly(polygon: Polygon, stroke: Stroke?, fill: Paint?) = present(stroke, fill) {
        when {
            !polygon.empty -> makeClosedPath(*polygon.points.toTypedArray())
            else           -> null
        }
    }

    private fun drawArc(center: Point, radius: Double, sweep: Measure, rotation: Measure, stroke: Stroke?, fill: Paint?) = present(stroke, fill) {
        when {
            radius <= 0 || sweep == 0 * degrees -> null
            sweep < 360 * degrees               -> makeArc(center, radius, sweep, rotation)
            else                                -> makeCircle(Circle(center, radius))
        }
    }

    private fun drawWedge(center: Point, radius: Double, sweep: Measure, rotation: Measure, stroke: Stroke?, fill: Paint?) = present(stroke, fill) {
        when {
            radius <= 0 || sweep == 0 * degrees -> null
            sweep < 360 * degrees               -> makeWedge(center, radius, sweep, rotation)
            else                                -> makeCircle(Circle(center, radius))
        }
    }

    private fun drawCircle(circle: Circle, stroke: Stroke?, fill: Paint?) = present(stroke, fill) {
        when {
            !circle.empty -> makeCircle(circle)
            else          -> null
        }
    }

    private fun drawEllipse(ellipse: Ellipse, stroke: Stroke?, fill: Paint?) = present(stroke, fill) {
        when {
            !ellipse.empty -> makeEllipse(ellipse)
            else           -> null
        }
    }

    private fun makeRoundedRect(rectangle: Rectangle, radius: Double): SVGRectElement = makeRect(rectangle).apply {
        setRX(radius)
        setRY(radius)

        setFill  (null)
        setStroke(null)
    }

    private fun makeCircle(circle: Circle): SVGCircleElement = createOrUse("circle").apply {
        setCircle(circle)

        setFill  (null)
        setStroke(null)
    }

    private fun makeEllipse(ellipse: Ellipse): SVGEllipseElement = createOrUse("ellipse").apply {
        setEllipse(ellipse)

        setFill  (null)
        setStroke(null)
    }

    private fun makeArc  (center: Point, radius: Double, sweep: Measure, rotation: Measure) = withPath("${makeArcPathData(center, radius, sweep, rotation)}Z")
    private fun makeWedge(center: Point, radius: Double, sweep: Measure, rotation: Measure) = withPath("${makeArcPathData(center, radius, sweep, rotation)} L${center.x},${center.y}Z")

    private fun withPath(path: String): SVGPathElement = createOrUse("path").apply {
        setPathData(path)
        setFill    (null)
        setStroke  (null)
    }

    private fun makeArcPathData(center: Point, radius: Double, sweep: Measure, rotation: Measure): String {
        val startX = center.x + radius * cos(rotation)
        val startY = center.y - radius * sin(rotation)
        val endX   = center.x + radius * cos(sweep + rotation)
        val endY   = center.y - radius * sin(sweep + rotation)

        val largeArc = if (sweep > 180 * degrees) "1" else "0"

        return "M$startX,$startY A$radius,$radius ${rotation `in` degrees } $largeArc,0 $endX,${endY}"
    }

    private fun makePath(vararg points: Point): SVGPathElement {
        val path = SVGPath()

        path.addPath(*points)
        path.end()

        return makePath(path)
    }

    private fun makeClosedPath(vararg points: Point) = createOrUse("polygon").apply {
        setPoints(*points)
    }

    private fun makePath(path: Path) = makePath(path.data)

    private fun makePath(pathData: String) = createOrUse("path").apply {
        setPathData(pathData)
    }

    private fun outlineElement(element: SVGGraphicsElement, stroke: Stroke, clearFill: Boolean = true) {
        if (!stroke.visible) {
            return
        }

        if (clearFill) {
            element.setFill(null)
        }

        strokeElement(element, stroke)
    }

    private fun fillElement(element: SVGGraphicsElement, fill: Paint, clearOutline: Boolean = true) {
        when (fill) {
            is ColorPaint          -> SolidFillHandler.fill         (this, element, fill)
            is PatternPaint        -> canvasFillHandler.fill        (this, element, fill)
            is LinearGradientPaint -> linearGradientFillHandler.fill(this, element, fill)
            is RadialGradientPaint -> radialGradientFillHandler.fill(this, element, fill)
            is ImagePaint          -> imageFillHandler.fill         (this, element, fill)
            is SweepGradientPaint  -> sweepGradientFillHandler.fill (this, element, fill)
        }

        if (clearOutline) {
            element.setStroke(null)
        }
    }

    private fun strokeElement(element: SVGGraphicsElement, stroke: Stroke) {
        when (val fill = stroke.fill) {
            is ColorPaint          -> SolidFillHandler.stroke         (this, element, fill, stroke)
            is PatternPaint        -> canvasFillHandler.stroke        (this, element, fill, stroke)
            is LinearGradientPaint -> linearGradientFillHandler.stroke(this, element, fill, stroke)
            is RadialGradientPaint -> radialGradientFillHandler.stroke(this, element, fill, stroke)
            is ImagePaint          -> imageFillHandler.stroke         (this, element, fill, stroke)
            is SweepGradientPaint  -> sweepGradientFillHandler.stroke (this, element, fill, stroke)
        }

        element.setStroke(stroke)
    }

    private fun textBackground(fill: ColorPaint) = createOrUse("filter").apply {
        if (id.isBlank()) { setId(nextId()) }

        setBounds(Rectangle(size = Size(1)))

        var index = 0
        val `in`  = "in"

        val oldRenderPosition = renderPosition

        renderPosition = firstChild

        addIfNotPresent(createOrUse("feFlood").apply {
            setFloodColor(fill.color)
            setAttribute("flood-opacity", "${fill.color.opacity}")
        }, index++)

        renderPosition = renderPosition?.nextSibling

        addIfNotPresent(createOrUse("feComposite").apply {
            setAttribute(`in`,       "SourceGraphic")
            setAttribute("operator", "over"         )
        }, index)

        renderPosition = renderPosition?.nextSibling

        internalFlush()
        renderPosition = if (parentNode != null) this else oldRenderPosition
    }

    private val shadowFinalizers = mutableListOf<() -> Unit>()

    internal fun finalizeShadows() {
        shadowFinalizers.forEach { it() }
        shadowFinalizers.clear()
    }

    private fun outerShadow(shadow: OuterShadow) = createOrUse("filter").apply {
        if (id.isBlank()) { setId(nextId()) }

        setAttribute("filterUnits", "userSpaceOnUse")

        // this needs to happen after svgElement has a chance to expand in size
        // so, it is done during flush()
        shadowFinalizers += {
            with(shadow) {
                val factor    = 2.6
                val svgBounds = svgElement?.getBBox_(BoundingBoxOptions())?.run { Rectangle(x, y, width, height) } ?: Rectangle([email protected])

                setX     (svgBounds.x - blurRadius * factor + min(0.0, horizontal)    )
                setY     (svgBounds.y - blurRadius * factor + min(0.0, vertical  )    )
                setWidth (svgBounds.width  + 2 * blurRadius * factor + abs(horizontal))
                setHeight(svgBounds.height + 2 * blurRadius * factor + abs(vertical  ))
            }
        }

        val oldRenderPosition = renderPosition

        renderPosition = firstChild
        var index      = 0

        addIfNotPresent(createOrUse("feOffset").apply {
            setAttribute("dx", "${shadow.horizontal}")
            setAttribute("dy", "${shadow.vertical  }")
        }, index++)

        renderPosition = renderPosition?.nextSibling

        addIfNotPresent(createOrUse("feGaussianBlur").apply {
            setAttribute("stdDeviation", "${shadow.blurRadius - 1}")
        }, index++)

        renderPosition = renderPosition?.nextSibling

        with(shadow.color) {
            addIfNotPresent(createOrUse("feColorMatrix").apply {
                setAttribute("type",   "matrix")
                setAttribute("values", "0 0 0 0 ${red.toDouble() / 255}, 0 0 0 0 ${green.toDouble() / 255}, 0 0 0 0 ${blue.toDouble() / 255}, 0 0 0 $opacity 0")
                setAttribute("result", "shadow")
            }, index++)
        }

        renderPosition = renderPosition?.nextSibling

        addIfNotPresent(createOrUse("feMerge").apply {
            var innerIndex = 0

            renderPosition = firstChild

            addIfNotPresent(createOrUse("feMergeNode").apply {
                setAttribute("in", "shadow")
            }, innerIndex++)

            renderPosition = renderPosition?.nextSibling

            addIfNotPresent(createOrUse("feMergeNode").apply {
                setAttribute("in", "SourceGraphic")
            }, innerIndex)
        }, index++)

        renderPosition = renderPosition?.nextSibling

        internalFlush()
        renderPosition = if (parentNode != null) this else oldRenderPosition
    }

    private fun innerShadow(shadow: InnerShadow) = createOrUse("filter").apply {
        if (id.isBlank()) { setId(nextId()) }

        val oldRenderPosition = renderPosition

        renderPosition = firstChild

        // TODO: Make first-class methods for these attributes
        var index   = 0
        val `in`    = "in"
        val in2     = "in2"
        val result  = "result"
        val inverse = "inverse"

        // Shadow Offset
        addIfNotPresent(createOrUse("feOffset").apply {
            setAttribute("dx", "${shadow.horizontal}")
            setAttribute("dy", "${shadow.vertical  }")
        }, index++)

        renderPosition = renderPosition?.nextSibling

        // Shadow Blur
        addIfNotPresent(createOrUse("feGaussianBlur").apply {
            setAttribute("stdDeviation", "${shadow.blurRadius}")
            setAttribute(result,         "offset-blur"         )
        }, index++)

        renderPosition = renderPosition?.nextSibling

        // Invert the drop shadow to create an inner shadow
        addIfNotPresent(createOrUse("feComposite").apply {
            setAttribute("operator", "out"          )
            setAttribute(`in`,       "SourceGraphic")
            setAttribute(in2,        "offset-blur"  )
            setAttribute(result,     inverse        )
        }, index++)

        renderPosition = renderPosition?.nextSibling

        addIfNotPresent(createOrUse("feFlood").apply {
            setFloodColor(shadow.color)
            setAttribute("flood-opacity", "${shadow.color.opacity}")
            setAttribute(result,          "color"                  )
        }, index++)

        renderPosition = renderPosition?.nextSibling

        // Clip color inside shadow
        addIfNotPresent(createOrUse("feComposite").apply {
            setAttribute("operator", `in`     )
            setAttribute(`in`,       "color"  )
            setAttribute(in2,        inverse  )
            setAttribute("result",   "shadow" )
        }, index++)

        renderPosition = renderPosition?.nextSibling

        // Put shadow over original object
        addIfNotPresent(createOrUse("feComposite").apply {
            setAttribute("operator", "over"         )
            setAttribute(`in`,       "shadow"       )
            setAttribute(in2,        "SourceGraphic")
        }, index)

        renderPosition = renderPosition?.nextSibling

        internalFlush()
        renderPosition = if (parentNode != null) this else oldRenderPosition
    }

    private fun findSvgDepthFirst(parent: Node): SVGElement? {
        if (parent is SVGElement) return parent

        var svg = null as SVGElement?

        (0 until parent.childNodes.length).mapNotNull { parent.childNodes[it] }.forEach {
            svg = findSvgDepthFirst(it)

            if (svg != null) {
                return svg
            }
        }

        return svg
    }

    private class SVGPath: Path("M", "L", "Z")

    private interface FillHandler {
        fun fill  (renderer: VectorRendererSvg, element: SVGGraphicsElement, paint: B)
        fun stroke(renderer: VectorRendererSvg, element: SVGGraphicsElement, paint: B, stroke: Stroke)
    }

    private object SolidFillHandler: FillHandler {
        override fun fill(renderer: VectorRendererSvg, element: SVGGraphicsElement, paint: ColorPaint) {
            element.setFill(paint.color)
        }

        override fun stroke(renderer: VectorRendererSvg, element: SVGGraphicsElement, paint: ColorPaint, stroke: Stroke) {
            element.setStrokeColor(paint.color)
        }
    }

    // TODO: Explore using a filter for this instead: https://www.smashingmagazine.com/2015/05/why-the-svg-filter-is-awesome/#image-fill
    private val canvasFillHandler: FillHandler by lazy {
        object: FillHandler {
            private fun makeFill(renderer: VectorRendererSvg, paint: PatternPaint): SVGElement {
                // FIXME: Re-use elements when possible
                val pattern = createOrUse("pattern").apply {
                    if (id.isBlank()) { setId(nextId()) }

                    setAttribute("patternUnits", "userSpaceOnUse")

                    if (!paint.transform.isIdentity) {
                        setPatternTransform(paint.transform)
                    }

                    setBounds(paint.bounds)
                    clear    (            )
                }

                renderer.completeOperation(pattern)

                val canvas = PatternCanvas(object: CanvasContext {
                    override var size get()            = paint.bounds.size; set(@Suppress("UNUSED_PARAMETER") value) {}
                    override val renderRegion          = pattern
                    override var renderPosition: Node? = pattern
                    override val shadows get()         = context.shadows
                    override fun markDirty()           = context.markDirty()
                    override val isRawData get()       = context.isRawData
                }, svgFactory, htmlFactory, aligner, idGenerator, pattern)

                paint.paint(canvas)

                shadowFinalizers += {
                    canvas.finalizeShadows()
                }

                return pattern
            }

            override fun fill(renderer: VectorRendererSvg, element: SVGGraphicsElement, paint: PatternPaint) {
                element.setFillPattern(makeFill(renderer, paint), paint.opacity)
            }

            override fun stroke(renderer: VectorRendererSvg, element: SVGGraphicsElement, paint: PatternPaint, stroke: Stroke) {
                element.setStrokePattern(makeFill(renderer, paint), paint.opacity)
            }
        }
    }

    private val imageFillHandler: FillHandler by lazy {
        object: FillHandler {
            private fun makeFill(renderer: VectorRendererSvg, paint: ImagePaint): SVGElement {
                // FIXME: Re-use elements when possible
                val pattern = createOrUse("pattern").apply {
                    if (id.isBlank()) { setId(nextId()) }

                    setAttribute("patternUnits", "userSpaceOnUse")

                    val destination = Rectangle(paint.size)

                    setBounds(destination)
                    clear    (           )

                    add(createImage(paint.image, destination, opacity = paint.opacity, radius = 0.0))
                }

                renderer.completeOperation(pattern)

                return pattern
            }

            override fun fill(renderer: VectorRendererSvg, element: SVGGraphicsElement, paint: ImagePaint) {
                element.setFillPattern(makeFill(renderer, paint))
            }

            override fun stroke(renderer: VectorRendererSvg, element: SVGGraphicsElement, paint: ImagePaint, stroke: Stroke) {
                element.setStrokePattern(makeFill(renderer, paint))
            }
        }
    }

    private val linearGradientFillHandler by lazy {
        object: FillHandler {
            private fun makeFill(renderer: VectorRendererSvg, paint: LinearGradientPaint): SVGLinearGradientElement {
                val gradient = createOrUse("linearGradient").apply {
                    if (id.isBlank()) { setId(nextId()) }

                    setGradientUnits("userSpaceOnUse")
                    setX1(paint.start.x              )
                    setY1(paint.start.y              )
                    setX2(paint.end.x                )
                    setY2(paint.end.y                )

                    updateStops(paint.colors    )
                }

                renderer.completeOperation(gradient)

                return gradient
            }

            override fun fill(renderer: VectorRendererSvg, element: SVGGraphicsElement, paint: LinearGradientPaint) {
                element.setFillPattern(makeFill(renderer, paint))
            }

            override fun stroke(renderer: VectorRendererSvg, element: SVGGraphicsElement, paint: LinearGradientPaint, stroke: Stroke) {
                element.setStrokePattern(makeFill(renderer, paint))
            }
        }
    }

    private val radialGradientFillHandler by lazy {
        object: FillHandler {
            private fun makeFill(renderer: VectorRendererSvg, paint: RadialGradientPaint): SVGRadialGradientElement {
                val gradient = createOrUse("radialGradient").apply {
                    if (id.isBlank()) { setId(nextId()) }

                    setGradientUnits("userSpaceOnUse")
                    setStart        (paint.start     )
                    setEnd          (paint.end       )
                    updateStops     (paint.colors    )
                }

                renderer.completeOperation(gradient)

                return gradient
            }

            override fun fill(renderer: VectorRendererSvg, element: SVGGraphicsElement, paint: RadialGradientPaint) {
                element.setFillPattern(makeFill(renderer, paint))
            }

            override fun stroke(renderer: VectorRendererSvg, element: SVGGraphicsElement, paint: RadialGradientPaint, stroke: Stroke) {
                element.setStrokePattern(makeFill(renderer, paint))
            }
        }
    }

    private val sweepGradientFillHandler by lazy {
        object: FillHandler {
            private fun makeForeign(mask: SVGElement, paint: SweepGradientPaint) = createOrUse("foreignObject").apply {
                setAttribute("mask", "url(#${mask.id})")

                appendChild(htmlFactory.create().apply {
                    val colors = paint.colors.joinToString(",") {
                        "${it.color.rgbaString} ${it.offset * 360 * degrees `in` degrees}deg"
                    }

                    style.background = "conic-gradient(from ${(paint.rotation `in` degrees) + 90.0}deg at ${paint.center.x}px ${paint.center.y}px, $colors)"
                    style.setWidthPercent (100.0)
                    style.setHeightPercent(100.0)
                })
            }

            private fun makeMask(element: SVGGraphicsElement, config: SVGElement.() -> Unit) = createOrUse("mask").apply {
                if (id.isBlank()) {
                    setId(nextId())
                }

                element.setFill(null)
                appendChild(element.cloneNode(deep = true).also { (it as SVGElement).config() })
            }

            private fun makeFill(renderer: VectorRendererSvg, paint: SweepGradientPaint, element: SVGGraphicsElement) {
                val mask = makeMask(element) {
                    setFill  (White)
                    setStroke(null )
                }

                renderer.completeOperation(mask)
                renderer.completeOperation(makeForeign(mask, paint).apply {
                    val bbox = element.getBBox_(BoundingBoxOptions())

                    setAttribute("width",  "${bbox.x + bbox.width }")
                    setAttribute("height", "${bbox.y + bbox.height}")
                })
            }

            private fun makeStroke(renderer: VectorRendererSvg, paint: SweepGradientPaint, element: SVGGraphicsElement, stroke: Stroke) {
                val mask = makeMask(element) {
                    setFill  (null  )
                    setStroke(Stroke(
                        dashes     = stroke.dashes,
                        lineCap    = stroke.lineCap,
                        lineJoint  = stroke.lineJoint,
                        thickness  = stroke.thickness,
                        dashOffset = stroke.dashOffset,
                    ))
                    setStrokeColor(White)
                }

                renderer.completeOperation(mask                   )
                renderer.completeOperation(makeForeign(mask, paint))
            }

            override fun fill(renderer: VectorRendererSvg, element: SVGGraphicsElement, paint: SweepGradientPaint) {
                makeFill(renderer, paint, element)
            }

            override fun stroke(renderer: VectorRendererSvg, element: SVGGraphicsElement, paint: SweepGradientPaint, stroke: Stroke) {
                makeStroke(renderer, paint, element, stroke)
            }
        }
    }

    private class ContextWrapper(delegate: CanvasContext): CanvasContext by delegate {
        override val shadows: MutableList = mutableListOf()
    }

    private fun createImage(image: Image, destination: Rectangle, radius: Double, opacity: Float) = createOrUse("image").apply {
        /*
         * xlink:href (https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/xlink:href) is deprecated for SVG 2.0, but Safari doesn't seem to support just href yet
         */
        setAttributeNS("http://www.w3.org/2000/svg",   "xlink", "http://www.w3.org/1999/xlink")
        setAttributeNS("http://www.w3.org/1999/xlink", "href",  image.source)
        setAttribute("preserveAspectRatio", "none")

        setBounds(destination)

        style.apply {
            setOpacity     (opacity)
            setBorderRadius(radius )
        }
    }

    private fun SVGElement.updateStops(stops: List) {
        stops.forEachIndexed { index, stop ->
            when (val child = childAt(index)) {
                is SVGElement -> child.apply {
                    setStopColor (stop.color )
                    setStopOffset(stop.offset)
                }
                else -> add(svgFactory("stop").apply {
                    setStopColor (stop.color )
                    setStopOffset(stop.offset)
                })
            }
        }
    }

    private companion object {
        private val containerElements = arrayOf("svg", "filter", "linearGradient", "radialGradient", "feMerge")
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy