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" + 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 " 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 "\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