Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/pixie.nim
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import
pixie/fileformats/[bmp, gif, jpeg, png, ppm, qoi, svg, webp]

export bumpy, chroma, common, contexts, fonts, imagebase64, images, paints,
paths, vmath
paths, svg, vmath

type
FileFormat* = enum
Expand Down
129 changes: 122 additions & 7 deletions src/pixie/fileformats/svg.nim
Original file line number Diff line number Diff line change
Expand Up @@ -507,13 +507,37 @@ proc parseSvg*(
if root.tag != "svg":
failInvalid()

let
viewBox = root.attr("viewBox")
box = viewBox.split(" ")
viewBoxMinX = parseInt(box[0])
viewBoxMinY = parseInt(box[1])
viewBoxWidth = parseInt(box[2])
viewBoxHeight = parseInt(box[3])
proc parseLen(s: string): int =
## Parses an SVG length, tolerating decimals and unit suffixes such as
## "px" (e.g. "230", "230.0", "230px" -> 230). Returns 0 for an empty or
## unparseable value.
var i = 0
while i < s.len and s[i] in {'0' .. '9', '.', '-', '+'}:
inc i
if i == 0:
return 0
try:
result = parseFloat(s[0 ..< i]).int
except ValueError:
result = 0

var viewBoxMinX, viewBoxMinY, viewBoxWidth, viewBoxHeight: int
let viewBox = root.attr("viewBox").strip()
if viewBox.len > 0:
let box = viewBox.replace(',', ' ').splitWhitespace()
if box.len != 4:
failInvalid()
viewBoxMinX = parseLen(box[0])
viewBoxMinY = parseLen(box[1])
viewBoxWidth = parseLen(box[2])
viewBoxHeight = parseLen(box[3])
else:
# No viewBox: fall back to the width/height attributes (valid SVG).
viewBoxWidth = parseLen(root.attr("width"))
viewBoxHeight = parseLen(root.attr("height"))
if viewBoxWidth == 0 or viewBoxHeight == 0:
raise newException(PixieError,
"SVG has neither a viewBox nor usable width/height attributes")

var rootProps = initSvgProperties()
rootProps = root.parseSvgProperties(rootProps)
Expand Down Expand Up @@ -554,6 +578,97 @@ proc parseSvg*(data: string, width = 0, height = 0): Svg {.raises: [PixieError].
except:
raise currentExceptionAsPixieError()

proc newSvg*(width, height: int): Svg {.raises: [].} =
## Creates an empty SVG scene for programmatic construction.
Svg(width: width, height: height)

proc addShape*(
svg: Svg, path: Path, fill: string, fillRule = NonZero,
fillOpacity: float32 = 1.0
) {.raises: [].} =
## Appends a filled path to the scene. `fill` is any CSS color string
## (e.g. "#ff0000").
var props = initSvgProperties()
props.fill = fill
props.fillRule = fillRule
props.fillOpacity = fillOpacity
svg.elements.add((path, props))

proc toSvgColor(c: ColorRGBX): string =
let c = c.rgba() # Premultiplied alpha back to straight alpha
result = "#"
result.add toHex(c.r.int, 2)
result.add toHex(c.g.int, 2)
result.add toHex(c.b.int, 2)
if c.a != 255:
result.add toHex(c.a.int, 2)

proc fmtNum(v: float32): string =
if floor(v) == v: $v.int else: $v

proc toSvgTransform(m: Mat3): string =
if m == mat3():
return ""
"matrix(" &
fmtNum(m[0, 0]) & "," & fmtNum(m[0, 1]) & "," &
fmtNum(m[1, 0]) & "," & fmtNum(m[1, 1]) & "," &
fmtNum(m[2, 0]) & "," & fmtNum(m[2, 1]) & ")"

proc toSvgString*(svg: Svg): string {.raises: [PixieError].} =
## Serializes an Svg scene to SVG markup. Gradient fills are not supported.
result = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
result.add "<svg xmlns=\"http://www.w3.org/2000/svg\""
result.add " width=\"" & $svg.width & "\""
result.add " height=\"" & $svg.height & "\""
result.add " viewBox=\"0 0 " & $svg.width & " " & $svg.height & "\">\n"
for (path, props) in svg.elements:
if not props.display:
continue
if props.fill.startsWith("url("):
raise newException(
PixieError, "toSvgString: gradient fills cannot be serialized"
)
result.add " <path d=\"" & $path & "\""
if props.fill == "none":
result.add " fill=\"none\""
else:
result.add " fill=\"" & props.fill & "\""
if props.fillRule == EvenOdd:
result.add " fill-rule=\"evenodd\""
if props.fillOpacity < 1.0:
result.add " fill-opacity=\"" & $props.fillOpacity & "\""
if props.stroke != rgbx(0, 0, 0, 0) and props.strokeWidth > 0:
result.add " stroke=\"" & toSvgColor(props.stroke) & "\""
result.add " stroke-width=\"" & fmtNum(props.strokeWidth) & "\""
if props.strokeLineCap != ButtCap:
result.add " stroke-linecap=\"" &
(case props.strokeLineCap
of ButtCap: "butt"
of RoundCap: "round"
of SquareCap: "square") & "\""
if props.strokeLineJoin != MiterJoin:
result.add " stroke-linejoin=\"" &
(case props.strokeLineJoin
of MiterJoin: "miter"
of RoundJoin: "round"
of BevelJoin: "bevel") & "\""
if props.strokeMiterLimit != defaultMiterLimit:
result.add " stroke-miterlimit=\"" & fmtNum(props.strokeMiterLimit) & "\""
if props.strokeDashArray.len > 0:
var dashes: seq[string]
for value in props.strokeDashArray:
dashes.add fmtNum(value)
result.add " stroke-dasharray=\"" & dashes.join(" ") & "\""
if props.strokeOpacity < 1.0:
result.add " stroke-opacity=\"" & $props.strokeOpacity & "\""
if props.opacity < 1.0:
result.add " opacity=\"" & $props.opacity & "\""
let transform = toSvgTransform(props.transform)
if transform.len > 0:
result.add " transform=\"" & transform & "\""
result.add "/>\n"
result.add "</svg>\n"

proc newImage*(svg: Svg): Image {.raises: [PixieError].} =
## Render SVG and return the image.
result = newImage(svg.width, svg.height)
Expand Down
2 changes: 1 addition & 1 deletion src/pixie/fonts.nim
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,7 @@ proc parseSvgFont*(buf: string): Typeface {.raises: [PixieError].} =
result = Typeface()
result.svgFont = svgfont.parseSvgFont(buf)

proc computePaths(arrangement: Arrangement): seq[Path] =
proc computePaths*(arrangement: Arrangement): seq[Path] =
## Takes an Arrangement and computes Paths for drawing.
## Returns a seq of paths that match the seq of Spans in the arrangement.
## If you only have one Span you will only get one Path.
Expand Down
56 changes: 55 additions & 1 deletion tests/test_svg.nim
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import pixie, pixie/fileformats/svg, strformat, xrays, xmlparser, xmltree
import pixie, pixie/fileformats/svg, strformat, strutils, xrays, xmlparser,
xmltree

const files = [
"line01",
Expand Down Expand Up @@ -38,3 +39,56 @@ block:
xmlNode,
512, 512
)

block:
# parseSvg accepts an SVG with width/height but no viewBox
let svg = parseSvg(
"""<svg xmlns="http://www.w3.org/2000/svg" width="64" height="48"></svg>"""
)
doAssert svg.width == 64
doAssert svg.height == 48

block:
# viewBox values may be comma-separated, decimal or carry a unit
let svg = parseSvg(
"""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0,0 64.0,48px"></svg>"""
)
doAssert svg.width == 64
doAssert svg.height == 48

block:
# newSvg + addShape + toSvgString produces valid re-parseable SVG
let
svg = newSvg(100, 100)
path = newPath()
path.rect(10, 10, 80, 80)
svg.addShape(path, "#ff0000")
let svg2 = parseSvg(svg.toSvgString())
doAssert svg2.width == 100
doAssert svg2.height == 100

block:
# fill-rule survives a round-trip
let
svg = newSvg(50, 50)
path = newPath()
path.rect(5, 5, 40, 40)
svg.addShape(path, "#00ff00", fillRule = EvenOdd)
let s = svg.toSvgString()
doAssert "fill-rule=\"evenodd\"" in s
doAssert parseSvg(s).toSvgString() == s

block:
# toSvgString raises for gradient fills
let svg = parseSvg("""<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10">
<linearGradient id="g" x1="0" y1="0" x2="10" y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#000000"/>
<stop offset="1" stop-color="#ffffff"/>
</linearGradient>
<rect x="0" y="0" width="10" height="10" fill="url(#g)"/>
</svg>""")
try:
discard svg.toSvgString()
doAssert false, "expected PixieError for gradient fill"
except PixieError:
discard