diff --git a/experiments/svg_cairo.nim b/experiments/svg_cairo.nim index e0b9900..e2d01ea 100644 --- a/experiments/svg_cairo.nim +++ b/experiments/svg_cairo.nim @@ -112,9 +112,13 @@ proc strokePath( const svgSignature* = "<?xml" type Ctx = object + fillRule: WindingRule fill, stroke: ColorRGBA strokeWidth: float32 + strokeLineCap: paths.LineCap + strokeLineJoin: paths.LineJoin transform: Mat3 + shouldStroke: bool when defined(pixieTestCairo): type RenderTarget = ptr Context @@ -124,21 +128,64 @@ else: template failInvalid() = raise newException(PixieError, "Invalid SVG data") +proc attrOrDefault(node: XmlNode, name, default: string): string = + result = node.attr(name) + if result.len == 0: + result = default + proc initCtx(): Ctx = - result.fill = parseHtmlColor("black").rgba.toPremultipliedAlpha() + result.fill = parseHtmlColor("black").rgba + result.stroke = parseHtmlColor("black").rgba result.strokeWidth = 1 result.transform = mat3() proc decodeCtx(inherited: Ctx, node: XmlNode): Ctx = result = inherited - let + var + fillRule = node.attr("fill-rule") fill = node.attr("fill") stroke = node.attr("stroke") strokeWidth = node.attr("stroke-width") + strokeLineCap = node.attr("stroke-linecap") + strokeLineJoin = node.attr("stroke-linejoin") transform = node.attr("transform") + style = node.attr("style") - if fill == "": + let pairs = style.split(';') + for pair in pairs: + let parts = pair.split(':') + if parts.len == 2: + # Do not override element properties + case parts[0].strip(): + of "fill": + if fill.len == 0: + fill = parts[1].strip() + of "stroke": + if stroke.len == 0: + stroke = parts[1].strip() + of "stroke-linecap": + if strokeLineCap.len == 0: + strokeLineCap = parts[1].strip() + of "stroke-linejoin": + if strokeLineJoin.len == 0: + strokeLineJoin = parts[1].strip() + of "stroke-width": + if strokeWidth.len == 0: + strokeWidth = parts[1].strip() + + if fillRule == "": + discard # Inherit + elif fillRule == "nonzero": + result.fillRule = wrNonZero + elif fillRule == "evenodd": + result.fillRule = wrEvenOdd + else: + raise newException( + PixieError, "Invalid fill-rule value " & fillRule + ) + + if fill == "" or fill == "currentColor": discard # Inherit elif fill == "none": result.fill = ColorRGBA() @@ -147,15 +194,58 @@ proc decodeCtx(inherited: Ctx, node: XmlNode): Ctx = if stroke == "": discard # Inherit + elif stroke == "currentColor": + result.shouldStroke = true elif stroke == "none": result.stroke = ColorRGBA() else: result.stroke = parseHtmlColor(stroke).rgba.toPremultipliedAlpha() + result.shouldStroke = true if strokeWidth == "": discard # Inherit else: + if strokeWidth.endsWith("px"): + strokeWidth = strokeWidth[0 .. ^3] result.strokeWidth = parseFloat(strokeWidth) + result.shouldStroke = true + + if result.stroke == ColorRGBA() or result.strokeWidth <= 0: + result.shouldStroke = false + + if strokeLineCap == "": + discard # Inherit + else: + case strokeLineCap: + of "butt": + result.strokeLineCap = lcButt + of "round": + result.strokeLineCap = lcRound + of "square": + result.strokeLineCap = lcSquare + of "inherit": + discard + else: + raise newException( + PixieError, "Invalid stroke-linecap value " & strokeLineCap + ) + + if strokeLineJoin == "": + discard # Inherit + else: + case strokeLineJoin: + of "miter": + result.strokeLineJoin = ljMiter + of "round": + result.strokeLineJoin = ljRound + of "bevel": + result.strokeLineJoin = ljBevel + of "inherit": + discard + else: + raise newException( + PixieError, "Invalid stroke-linejoin value " & strokeLineJoin + ) if transform == "": discard # Inherit @@ -174,38 +264,53 @@ proc decodeCtx(inherited: Ctx, node: XmlNode): Ctx = remaining = remaining[index + 1 .. ^1] if f.startsWith("matrix("): - let arr = f[7 .. ^2].split(",") + let arr = + if f.contains(","): + f[7 .. ^2].split(",") + else: + f[7 .. ^2].split(" ") if arr.len != 6: failInvalidTransform(transform) var m = mat3() - m[0] = parseFloat(arr[0]) - m[1] = parseFloat(arr[1]) - m[3] = parseFloat(arr[2]) - m[4] = parseFloat(arr[3]) - m[6] = parseFloat(arr[4]) - m[7] = parseFloat(arr[5]) + m[0] = parseFloat(arr[0].strip()) + m[1] = parseFloat(arr[1].strip()) + m[3] = parseFloat(arr[2].strip()) + m[4] = parseFloat(arr[3].strip()) + m[6] = parseFloat(arr[4].strip()) + m[7] = parseFloat(arr[5].strip()) result.transform = result.transform * m elif f.startsWith("translate("): let components = f[10 .. ^2].split(" ") - tx = parseFloat(components[0]) - ty = parseFloat(components[1]) + tx = parseFloat(components[0].strip()) + ty = + if components[1].len == 0: + 0.0 + else: + parseFloat(components[1].strip()) result.transform = result.transform * translate(vec2(tx, ty)) elif f.startsWith("rotate("): - let angle = parseFloat(f[7 .. ^2]) * -PI / 180 - result.transform = result.transform * rotationMat3(angle) + let + values = f[7 .. ^2].split(" ") + angle = parseFloat(values[0].strip()) * -PI / 180 + var cx, cy: float32 + if values.len > 1: + cx = parseFloat(values[1].strip()) + if values.len > 2: + cy = parseFloat(values[2].strip()) + let center = vec2(cx, cy) + result.transform = result.transform * + translate(center) * rotationMat3(angle) * translate(-center) else: failInvalidTransform(transform) -proc draw( - img: ptr Context, node: XmlNode, ctxStack: var seq[Ctx] -) = +proc draw(img: Image, node: XmlNode, ctxStack: var seq[Ctx]) = if node.kind != xnElement: # Skip <!-- comments --> return case node.tag: - of "title", "desc": + of "title", "desc", "defs": discard of "g": @@ -221,17 +326,17 @@ proc draw( ctx = decodeCtx(ctxStack[^1], node) path = parsePath(d) if ctx.fill != ColorRGBA(): - img.fillPath(path, ctx.fill, ctx.transform) - if ctx.stroke != ColorRGBA() and ctx.strokeWidth > 0: - img.strokePath(path, ctx.stroke, ctx.strokeWidth, ctx.transform) + img.fillPath(path, ctx.fill, ctx.transform, ctx.fillRule) + if ctx.shouldStroke: + img.strokePath(path, ctx.stroke, ctx.transform, ctx.strokeWidth) of "line": let ctx = decodeCtx(ctxStack[^1], node) - x1 = parseFloat(node.attr("x1")) - y1 = parseFloat(node.attr("y1")) - x2 = parseFloat(node.attr("x2")) - y2 = parseFloat(node.attr("y2")) + x1 = parseFloat(node.attrOrDefault("x1", "0")) + y1 = parseFloat(node.attrOrDefault("y1", "0")) + x2 = parseFloat(node.attrOrDefault("x2", "0")) + y2 = parseFloat(node.attrOrDefault("y2", "0")) var path: Path path.moveTo(x1, y1) @@ -240,8 +345,8 @@ proc draw( if ctx.fill != ColorRGBA(): img.fillPath(path, ctx.fill, ctx.transform) - if ctx.stroke != ColorRGBA() and ctx.strokeWidth > 0: - img.strokePath(path, ctx.stroke, ctx.strokeWidth, ctx.transform) + if ctx.shouldStroke: + img.strokePath(path, ctx.stroke, ctx.transform, ctx.strokeWidth) of "polyline", "polygon": let @@ -249,11 +354,18 @@ proc draw( points = node.attr("points") var vecs: seq[Vec2] - for pair in points.split(" "): - let parts = pair.split(",") - if parts.len != 2: + if points.contains(","): + for pair in points.split(" "): + let parts = pair.split(",") + if parts.len != 2: + failInvalid() + vecs.add(vec2(parseFloat(parts[0]), parseFloat(parts[1]))) + else: + let points = points.split(" ") + if points.len mod 2 != 0: failInvalid() - vecs.add(vec2(parseFloat(parts[0]), parseFloat(parts[1]))) + for i in countup(0, points.len - 2, 2): + vecs.add(vec2(parseFloat(points[i]), parseFloat(points[i + 1]))) if vecs.len == 0: failInvalid() @@ -269,65 +381,73 @@ proc draw( if ctx.fill != ColorRGBA(): img.fillPath(path, ctx.fill, ctx.transform) - if ctx.stroke != ColorRGBA() and ctx.strokeWidth > 0: - img.strokePath(path, ctx.stroke, ctx.strokeWidth, ctx.transform) + if ctx.shouldStroke: + img.strokePath(path, ctx.stroke, ctx.transform, ctx.strokeWidth) of "rect": let ctx = decodeCtx(ctxStack[^1], node) - x = parseFloat(node.attr("x")) - y = parseFloat(node.attr("y")) + x = parseFloat(node.attrOrDefault("x", "0")) + y = parseFloat(node.attrOrDefault("y", "0")) width = parseFloat(node.attr("width")) height = parseFloat(node.attr("height")) + var + rx = max(parseFloat(node.attrOrDefault("rx", "0")), 0) + ry = max(parseFloat(node.attrOrDefault("ry", "0")), 0) + var path: Path - path.rect(x, y, width, height) + if rx > 0 or ry > 0: + if rx == 0: + rx = ry + elif ry == 0: + ry = rx + rx = min(rx, width / 2) + ry = min(ry, height / 2) + + path.moveTo(x + rx, y) + path.lineTo(x + width - rx, y) + path.ellipticalArcTo(rx, ry, 0, false, true, x + width, y + ry) + path.lineTo(x + width, y + height - ry) + path.ellipticalArcTo(rx, ry, 0, false, true, x + width - rx, y + height) + path.lineTo(x + rx, y + height) + path.ellipticalArcTo(rx, ry, 0, false, true, x, y + height - ry) + path.lineTo(x, y + ry) + path.ellipticalArcTo(rx, ry, 0, false, true, x + rx, y) + else: + path.rect(x, y, width, height) if ctx.fill != ColorRGBA(): img.fillPath(path, ctx.fill, ctx.transform) - if ctx.stroke != ColorRGBA() and ctx.strokeWidth > 0: - img.strokePath(path, ctx.stroke, ctx.strokeWidth, ctx.transform) + if ctx.shouldStroke: + img.strokePath(path, ctx.stroke, ctx.transform, ctx.strokeWidth) of "circle", "ellipse": - # Reference for magic constant: - # https://dl3.pushbulletusercontent.com/a3fLVC8boTzRoxevD1OgCzRzERB9z2EZ/unknown.png - let ctx = decodeCtx(ctxStack[^1], node) - - var cx, cy: float32 # Default to 0.0 unless set by cx and cy on node - if node.attr("cx") != "": - cx = parseFloat(node.attr("cx")) - if node.attr("cy") != "": - cy = parseFloat(node.attr("cy")) + let + ctx = decodeCtx(ctxStack[^1], node) + cx = parseFloat(node.attrOrDefault("cx", "0")) + cy = parseFloat(node.attrOrDefault("cy", "0")) var rx, ry: float32 if node.tag == "circle": rx = parseFloat(node.attr("r")) ry = rx else: - rx = parseFloat(node.attr("rx")) - ry = parseFloat(node.attr("ry")) - - let - magicX = (4.0 * (-1.0 + sqrt(2.0)) / 3) * rx - magicY = (4.0 * (-1.0 + sqrt(2.0)) / 3) * ry + rx = parseFloat(node.attrOrDefault("rx", "0")) + ry = parseFloat(node.attrOrDefault("ry", "0")) var path: Path - path.moveTo(cx + rx, cy) - path.bezierCurveTo(cx + rx, cy + magicY, cx + magicX, cy + ry, cx, cy + ry) - path.bezierCurveTo(cx - magicX, cy + ry, cx - rx, cy + magicY, cx - rx, cy) - path.bezierCurveTo(cx - rx, cy - magicY, cx - magicX, cy - ry, cx, cy - ry) - path.bezierCurveTo(cx + magicX, cy - ry, cx + rx, cy - magicY, cx + rx, cy) - path.closePath() + path.ellipse(cx, cy, rx, ry) if ctx.fill != ColorRGBA(): img.fillPath(path, ctx.fill, ctx.transform) - if ctx.stroke != ColorRGBA() and ctx.strokeWidth > 0: - img.strokePath(path, ctx.stroke, ctx.strokeWidth, ctx.transform) + if ctx.shouldStroke: + img.strokePath(path, ctx.stroke, ctx.transform, ctx.strokeWidth) else: raise newException(PixieError, "Unsupported SVG tag: " & node.tag & ".") -proc decodeSvg*(data: string): Image = +proc decodeSvg*(data: string, width = 0, height = 0): Image = ## Render SVG file and return the image. try: let root = parseXml(data) @@ -337,27 +457,24 @@ proc decodeSvg*(data: string): Image = let viewBox = root.attr("viewBox") box = viewBox.split(" ") - if parseInt(box[0]) != 0 or parseInt(box[1]) != 0: - failInvalid() + viewBoxWidth = parseInt(box[2]) + viewBoxHeight = parseInt(box[3]) - let - width = parseInt(box[2]) - height = parseInt(box[3]) - var ctxStack = @[initCtx()] - result = newImage(width, height) - let - surface = imageSurfaceCreate(FORMAT_ARGB32, width.int32, height.int32) - c = surface.create() + var rootCtx = initCtx() + rootCtx = decodeCtx(rootCtx, root) + if width == 0 and height == 0: # Default to the view box size + result = newImage(viewBoxWidth, viewBoxHeight) + else: + result = newImage(width, height) + + let + scaleX = width.float32 / viewBoxWidth.float32 + scaleY = height.float32 / viewBoxHeight.float32 + rootCtx.transform = scale(vec2(scaleX, scaleY)) + + var ctxStack = @[rootCtx] for node in root: - c.draw(node, ctxStack) - surface.flush() - let pixels = cast[ptr UncheckedArray[array[4, uint8]]](surface.getData()) - for y in 0 ..< result.height: - for x in 0 ..< result.width: - let - bgra = pixels[result.dataIndex(x, y)] - rgba = rgba(bgra[2], bgra[1], bgra[0], bgra[3]) - result.setRgbaUnsafe(x, y, rgba) + result.draw(node, ctxStack) result.toStraightAlpha() except PixieError as e: raise e diff --git a/src/pixie/fileformats/svg.nim b/src/pixie/fileformats/svg.nim index 8a238c8..32fc68e 100644 --- a/src/pixie/fileformats/svg.nim +++ b/src/pixie/fileformats/svg.nim @@ -14,6 +14,7 @@ type Ctx = object strokeLineCap: LineCap strokeLineJoin: LineJoin transform: Mat3 + shouldStroke: bool template failInvalid() = raise newException(PixieError, "Invalid SVG data") @@ -24,7 +25,8 @@ proc attrOrDefault(node: XmlNode, name, default: string): string = result = default proc initCtx(): Ctx = - result.fill = parseHtmlColor("black").rgba.toPremultipliedAlpha() + result.fill = parseHtmlColor("black").rgba + result.stroke = parseHtmlColor("black").rgba result.strokeWidth = 1 result.transform = mat3() @@ -81,12 +83,15 @@ proc decodeCtx(inherited: Ctx, node: XmlNode): Ctx = else: result.fill = parseHtmlColor(fill).rgba.toPremultipliedAlpha() - if stroke == "" or fill == "currentColor": + if stroke == "": discard # Inherit + elif stroke == "currentColor": + result.shouldStroke = true elif stroke == "none": result.stroke = ColorRGBA() else: result.stroke = parseHtmlColor(stroke).rgba.toPremultipliedAlpha() + result.shouldStroke = true if strokeWidth == "": discard # Inherit @@ -94,6 +99,10 @@ proc decodeCtx(inherited: Ctx, node: XmlNode): Ctx = if strokeWidth.endsWith("px"): strokeWidth = strokeWidth[0 .. ^3] result.strokeWidth = parseFloat(strokeWidth) + result.shouldStroke = true + + if result.stroke == ColorRGBA() or result.strokeWidth <= 0: + result.shouldStroke = false if strokeLineCap == "": discard # Inherit @@ -154,25 +163,35 @@ proc decodeCtx(inherited: Ctx, node: XmlNode): Ctx = if arr.len != 6: failInvalidTransform(transform) var m = mat3() - m[0] = parseFloat(arr[0]) - m[1] = parseFloat(arr[1]) - m[3] = parseFloat(arr[2]) - m[4] = parseFloat(arr[3]) - m[6] = parseFloat(arr[4]) - m[7] = parseFloat(arr[5]) + m[0] = parseFloat(arr[0].strip()) + m[1] = parseFloat(arr[1].strip()) + m[3] = parseFloat(arr[2].strip()) + m[4] = parseFloat(arr[3].strip()) + m[6] = parseFloat(arr[4].strip()) + m[7] = parseFloat(arr[5].strip()) result.transform = result.transform * m elif f.startsWith("translate("): let components = f[10 .. ^2].split(" ") - tx = parseFloat(components[0]) - ty = parseFloat(components[1]) + tx = parseFloat(components[0].strip()) + ty = + if components[1].len == 0: + 0.0 + else: + parseFloat(components[1].strip()) result.transform = result.transform * translate(vec2(tx, ty)) elif f.startsWith("rotate("): - # let - # values = f[7 .. ^2].split(" ") - # angle = parseFloat(values[0]) * -PI / 180 - let angle = parseFloat(f[7 .. ^2]) * -PI / 180 - result.transform = result.transform * rotationMat3(angle) + let + values = f[7 .. ^2].split(" ") + angle = parseFloat(values[0].strip()) * -PI / 180 + var cx, cy: float32 + if values.len > 1: + cx = parseFloat(values[1].strip()) + if values.len > 2: + cy = parseFloat(values[2].strip()) + let center = vec2(cx, cy) + result.transform = result.transform * + translate(center) * rotationMat3(angle) * translate(-center) else: failInvalidTransform(transform) @@ -182,7 +201,7 @@ proc draw(img: Image, node: XmlNode, ctxStack: var seq[Ctx]) = return case node.tag: - of "title", "desc": + of "title", "desc", "defs": discard of "g": @@ -199,7 +218,7 @@ proc draw(img: Image, node: XmlNode, ctxStack: var seq[Ctx]) = path = parsePath(d) if ctx.fill != ColorRGBA(): img.fillPath(path, ctx.fill, ctx.transform, ctx.fillRule) - if ctx.stroke != ColorRGBA() and ctx.strokeWidth > 0: + if ctx.shouldStroke: img.strokePath(path, ctx.stroke, ctx.transform, ctx.strokeWidth) of "line": @@ -217,7 +236,7 @@ proc draw(img: Image, node: XmlNode, ctxStack: var seq[Ctx]) = if ctx.fill != ColorRGBA(): img.fillPath(path, ctx.fill, ctx.transform) - if ctx.stroke != ColorRGBA() and ctx.strokeWidth > 0: + if ctx.shouldStroke: img.strokePath(path, ctx.stroke, ctx.transform, ctx.strokeWidth) of "polyline", "polygon": @@ -253,7 +272,7 @@ proc draw(img: Image, node: XmlNode, ctxStack: var seq[Ctx]) = if ctx.fill != ColorRGBA(): img.fillPath(path, ctx.fill, ctx.transform) - if ctx.stroke != ColorRGBA() and ctx.strokeWidth > 0: + if ctx.shouldStroke: img.strokePath(path, ctx.stroke, ctx.transform, ctx.strokeWidth) of "rect": @@ -291,7 +310,7 @@ proc draw(img: Image, node: XmlNode, ctxStack: var seq[Ctx]) = if ctx.fill != ColorRGBA(): img.fillPath(path, ctx.fill, ctx.transform) - if ctx.stroke != ColorRGBA() and ctx.strokeWidth > 0: + if ctx.shouldStroke: img.strokePath(path, ctx.stroke, ctx.transform, ctx.strokeWidth) of "circle", "ellipse": @@ -313,7 +332,7 @@ proc draw(img: Image, node: XmlNode, ctxStack: var seq[Ctx]) = if ctx.fill != ColorRGBA(): img.fillPath(path, ctx.fill, ctx.transform) - if ctx.stroke != ColorRGBA() and ctx.strokeWidth > 0: + if ctx.shouldStroke: img.strokePath(path, ctx.stroke, ctx.transform, ctx.strokeWidth) else: @@ -329,15 +348,11 @@ proc decodeSvg*(data: string, width = 0, height = 0): Image = let viewBox = root.attr("viewBox") box = viewBox.split(" ") - - # if parseInt(box[0]) != 0 or parseInt(box[1]) != 0: - # failInvalid() - - let viewBoxWidth = parseInt(box[2]) viewBoxHeight = parseInt(box[3]) var rootCtx = initCtx() + rootCtx = decodeCtx(rootCtx, root) if width == 0 and height == 0: # Default to the view box size result = newImage(viewBoxWidth, viewBoxHeight) else: diff --git a/src/pixie/paths.nim b/src/pixie/paths.nim index d626610..c7c5233 100644 --- a/src/pixie/paths.nim +++ b/src/pixie/paths.nim @@ -1278,7 +1278,7 @@ proc parseSomePath( parsePath(path).commandsToShapes(pixelScale) elif type(path) is Path: path.commandsToShapes(pixelScale) - elif type(path) is seq[seq[Segment]]: + elif type(path) is seq[seq[Vec2]]: path proc fillPath*( diff --git a/tests/images/svg/tabler-icons.png b/tests/images/svg/tabler-icons.png new file mode 100644 index 0000000..a9bc3ec Binary files /dev/null and b/tests/images/svg/tabler-icons.png differ diff --git a/tests/megatest.nim b/tests/megatest.nim index 992140d..3084917 100644 --- a/tests/megatest.nim +++ b/tests/megatest.nim @@ -24,7 +24,7 @@ const IconSet(name: "twbs-icons", path: "../icons/icons/*"), IconSet(name: "flat-color-icons", path: "../flat-color-icons/svg/*"), IconSet(name: "ionicons", path: "../ionicons/src/svg/*"), - # IconSet(name: "tabler-icons", path: "../tabler-icons/icons/*"), + IconSet(name: "tabler-icons", path: "../tabler-icons/icons/*"), IconSet(name: "simple-icons", path: "../simple-icons/icons/*") ] width = 32