diff --git a/src/pixie.nim b/src/pixie.nim
index e830e082..fc674eba 100644
--- a/src/pixie.nim
+++ b/src/pixie.nim
@@ -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
diff --git a/src/pixie/fileformats/svg.nim b/src/pixie/fileformats/svg.nim
index 047566ee..0be0e29d 100644
--- a/src/pixie/fileformats/svg.nim
+++ b/src/pixie/fileformats/svg.nim
@@ -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)
@@ -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 = "\n"
+ result.add "\n"
+
proc newImage*(svg: Svg): Image {.raises: [PixieError].} =
## Render SVG and return the image.
result = newImage(svg.width, svg.height)
diff --git a/src/pixie/fonts.nim b/src/pixie/fonts.nim
index 7c3f09b4..49bc6688 100644
--- a/src/pixie/fonts.nim
+++ b/src/pixie/fonts.nim
@@ -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.
diff --git a/tests/test_svg.nim b/tests/test_svg.nim
index 13b22bd8..e66edcb0 100644
--- a/tests/test_svg.nim
+++ b/tests/test_svg.nim
@@ -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",
@@ -38,3 +39,56 @@ block:
xmlNode,
512, 512
)
+
+block:
+ # parseSvg accepts an SVG with width/height but no viewBox
+ let svg = parseSvg(
+ """"""
+ )
+ doAssert svg.width == 64
+ doAssert svg.height == 48
+
+block:
+ # viewBox values may be comma-separated, decimal or carry a unit
+ let svg = parseSvg(
+ """"""
+ )
+ 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("""""")
+ try:
+ discard svg.toSvgString()
+ doAssert false, "expected PixieError for gradient fill"
+ except PixieError:
+ discard