diff --git a/pixie.nimble b/pixie.nimble index 499aa10..3a9c108 100644 --- a/pixie.nimble +++ b/pixie.nimble @@ -11,7 +11,7 @@ requires "chroma >= 0.2.5" requires "zippy >= 0.9.7" requires "flatty >= 0.3.0" requires "nimsimd >= 1.0.0" -requires "bumpy >= 1.1.0" +requires "bumpy >= 1.1.1" task bindings, "Generate bindings": diff --git a/src/pixie.nim b/src/pixie.nim index d5abcc3..6fc8608 100644 --- a/src/pixie.nim +++ b/src/pixie.nim @@ -21,18 +21,18 @@ converter autoPremultipliedAlpha*(c: ColorRGBA): ColorRGBX {.inline, raises: []. proc decodeImage*(data: string): Image {.raises: [PixieError].} = ## Loads an image from memory. if data.len > 8 and data.readUint64(0) == cast[uint64](pngSignature): - decodePng(data) + newImage(decodePng(data)) elif data.len > 2 and data.readUint16(0) == cast[uint16](jpegStartOfImage): decodeJpeg(data) elif data.len > 2 and data.readStr(0, 2) == bmpSignature: decodeBmp(data) elif data.len > 5 and (data.readStr(0, 5) == xmlSignature or data.readStr(0, 4) == svgSignature): - decodeSvg(data) + newImage(parseSvg(data)) elif data.len > 6 and data.readStr(0, 6) in gifSignatures: decodeGif(data) elif data.len > (14+8) and data.readStr(0, 4) == qoiSignature: - decodeQoi(data) + newImage(decodeQoi(data)) elif data.len > 9 and data.readStr(0, 2) in ppmSignatures: decodePpm(data) else: @@ -41,7 +41,7 @@ proc decodeImage*(data: string): Image {.raises: [PixieError].} = proc decodeMask*(data: string): Mask {.raises: [PixieError].} = ## Loads a mask from memory. if data.len > 8 and data.readUint64(0) == cast[uint64](pngSignature): - newMask(decodePng(data)) + newMask(newImage(decodePng(data))) else: raise newException(PixieError, "Unsupported mask file format") diff --git a/src/pixie/contexts.nim b/src/pixie/contexts.nim index e1a9f2b..1e48e5a 100644 --- a/src/pixie/contexts.nim +++ b/src/pixie/contexts.nim @@ -7,7 +7,6 @@ import bumpy, chroma, pixie/common, pixie/fonts, pixie/images, pixie/masks, ## https://developer.mozilla.org/en-US/docs/Web/API/ContextRenderingContext2D type - BaselineAlignment* = enum TopBaseline HangingBaseline diff --git a/src/pixie/fileformats/png.nim b/src/pixie/fileformats/png.nim index fb96177..30e3442 100644 --- a/src/pixie/fileformats/png.nim +++ b/src/pixie/fileformats/png.nim @@ -342,7 +342,7 @@ proc newImage*(png: Png): Image {.raises: [PixieError].} = copyMem(result.data[0].addr, png.data[0].addr, png.data.len * 4) result.data.toPremultipliedAlpha() -proc decodePngRaw*(data: string): Png {.raises: [PixieError].} = +proc decodePng*(data: string): Png {.raises: [PixieError].} = ## Decodes the PNG data. if data.len < (8 + (8 + 13 + 4) + 4): # Magic bytes + IHDR + IEND failInvalid() @@ -450,10 +450,6 @@ proc decodePngRaw*(data: string): Png {.raises: [PixieError].} = result.channels = 4 result.data = decodeImageData(data, header, palette, transparency, idats) -proc decodePng*(data: string): Image {.raises: [PixieError].} = - ## Decodes the PNG data into an Image. - newImage(decodePngRaw(data)) - proc encodePng*( width, height, channels: int, data: pointer, len: int ): string {.raises: [PixieError].} = diff --git a/src/pixie/fileformats/qoi.nim b/src/pixie/fileformats/qoi.nim index 5e2b02e..887191b 100644 --- a/src/pixie/fileformats/qoi.nim +++ b/src/pixie/fileformats/qoi.nim @@ -35,7 +35,7 @@ func newImage*(qoi: Qoi): Image = copyMem(result.data[0].addr, qoi.data[0].addr, qoi.data.len * 4) result.data.toPremultipliedAlpha() -proc decodeQoiRaw*(data: string): Qoi {.raises: [PixieError].} = +proc decodeQoi*(data: string): Qoi {.raises: [PixieError].} = ## Decompress QOI file format data. if data.len <= 14 or data[0 .. 3] != qoiSignature: raise newException(PixieError, "Invalid QOI header") @@ -121,10 +121,6 @@ proc decodeQoiRaw*(data: string): Qoi {.raises: [PixieError].} = raise newException(PixieError, "Invalid QOI padding") inc(p) -proc decodeQoi*(data: string): Image {.raises: [PixieError].} = - ## Decodes data in the QOI file format to an `Image`. - newImage(decodeQoiRaw(data)) - proc encodeQoi*(qoi: Qoi): string {.raises: [PixieError].} = ## Encodes raw QOI pixels to the QOI file format. diff --git a/src/pixie/fileformats/svg.nim b/src/pixie/fileformats/svg.nim index 71bcf61..168ad1f 100644 --- a/src/pixie/fileformats/svg.nim +++ b/src/pixie/fileformats/svg.nim @@ -11,14 +11,15 @@ const svgSignature* = " 0: - result.fill.opacity = parseFloat(fillOpacity).clamp(0, 1) + result.fillOpacity = parseFloat(fillOpacity).clamp(0, 1) if strokeOpacity.len > 0: result.strokeOpacity = parseFloat(strokeOpacity).clamp(0, 1) @@ -202,10 +187,8 @@ proc decodeCtxInternal(inherited: Ctx, node: XmlNode): Ctx = if strokeWidth.endsWith("px"): strokeWidth = strokeWidth[0 .. ^3] result.strokeWidth = parseFloat(strokeWidth) - result.shouldStroke = true - - if result.stroke == ColorRGBX() or result.strokeWidth <= 0: - result.shouldStroke = false + if result.stroke == rgbx(0, 0, 0, 0): + result.stroke = rgbx(0, 0, 0, 255) if strokeLineCap == "": discard # Inherit @@ -316,36 +299,9 @@ proc decodeCtxInternal(inherited: Ctx, node: XmlNode): Ctx = else: failInvalidTransform(transform) -proc decodeCtx(inherited: Ctx, node: XmlNode): Ctx = - try: - decodeCtxInternal(inherited, node) - except PixieError as e: - raise e - except: - raise currentExceptionAsPixieError() - -proc fill(img: Image, ctx: Ctx, path: Path) {.inline.} = - if ctx.display and ctx.opacity > 0: - let paint = newPaint(ctx.fill) - paint.opacity = paint.opacity * ctx.opacity - img.fillPath(path, paint, ctx.transform, ctx.fillRule) - -proc stroke(img: Image, ctx: Ctx, path: Path) {.inline.} = - if ctx.display and ctx.opacity > 0: - let paint = newPaint(ctx.stroke) - paint.color.a *= (ctx.opacity * ctx.strokeOpacity) - img.strokePath( - path, - paint, - ctx.transform, - ctx.strokeWidth, - ctx.strokeLineCap, - ctx.strokeLineJoin, - miterLimit = ctx.strokeMiterLimit, - dashes = ctx.strokeDashArray - ) - -proc drawInternal(img: Image, node: XmlNode, ctxStack: var seq[Ctx]) = +proc parseSvgElement( + node: XmlNode, svg: Svg, propertiesStack: var seq[SvgProperties] +): seq[(Path, SvgProperties)] = if node.kind != xnElement: # Skip return @@ -359,25 +315,23 @@ proc drawInternal(img: Image, node: XmlNode, ctxStack: var seq[Ctx]) = echo node of "g": - let ctx = decodeCtx(ctxStack[^1], node) - ctxStack.add(ctx) + let props = node.parseSvgProperties(propertiesStack[^1]) + propertiesStack.add(props) for child in node: - img.drawInternal(child, ctxStack) - discard ctxStack.pop() + result.add child.parseSvgElement(svg, propertiesStack) + discard propertiesStack.pop() of "path": let d = node.attr("d") - ctx = decodeCtx(ctxStack[^1], node) + props = node.parseSvgProperties(propertiesStack[^1]) path = parsePath(d) - img.fill(ctx, path) - if ctx.shouldStroke: - img.stroke(ctx, path) + result.add (path, props) of "line": let - ctx = decodeCtx(ctxStack[^1], node) + props = node.parseSvgProperties(propertiesStack[^1]) x1 = parseFloat(node.attrOrDefault("x1", "0")) y1 = parseFloat(node.attrOrDefault("y1", "0")) x2 = parseFloat(node.attrOrDefault("x2", "0")) @@ -387,12 +341,11 @@ proc drawInternal(img: Image, node: XmlNode, ctxStack: var seq[Ctx]) = path.moveTo(x1, y1) path.lineTo(x2, y2) - if ctx.shouldStroke: - img.stroke(ctx, path) + result.add (path, props) of "polyline", "polygon": let - ctx = decodeCtx(ctxStack[^1], node) + props = node.parseSvgProperties(propertiesStack[^1]) points = node.attr("points") var vecs: seq[Vec2] @@ -421,14 +374,12 @@ proc drawInternal(img: Image, node: XmlNode, ctxStack: var seq[Ctx]) = # and fill or not if node.tag == "polygon": path.closePath() - img.fill(ctx, path) - if ctx.shouldStroke: - img.stroke(ctx, path) + result.add (path, props) of "rect": let - ctx = decodeCtx(ctxStack[^1], node) + props = node.parseSvgProperties(propertiesStack[^1]) x = parseFloat(node.attrOrDefault("x", "0")) y = parseFloat(node.attrOrDefault("y", "0")) width = parseFloat(node.attrOrDefault("width", "0")) @@ -462,13 +413,11 @@ proc drawInternal(img: Image, node: XmlNode, ctxStack: var seq[Ctx]) = else: path.rect(x, y, width, height) - img.fill(ctx, path) - if ctx.shouldStroke: - img.stroke(ctx, path) + result.add (path, props) of "circle", "ellipse": let - ctx = decodeCtx(ctxStack[^1], node) + props = node.parseSvgProperties(propertiesStack[^1]) cx = parseFloat(node.attrOrDefault("cx", "0")) cy = parseFloat(node.attrOrDefault("cy", "0")) @@ -483,16 +432,14 @@ proc drawInternal(img: Image, node: XmlNode, ctxStack: var seq[Ctx]) = let path = newPath() path.ellipse(cx, cy, rx, ry) - img.fill(ctx, path) - if ctx.shouldStroke: - img.stroke(ctx, path) + result.add (path, props) of "radialGradient": discard of "linearGradient": let - ctx = decodeCtx(ctxStack[^1], node) + props = node.parseSvgProperties(propertiesStack[^1]) id = node.attr("id") gradientUnits = node.attr("gradientUnits") gradientTransform = node.attr("gradientTransform") @@ -547,23 +494,15 @@ proc drawInternal(img: Image, node: XmlNode, ctxStack: var seq[Ctx]) = else: raise newException(PixieError, "Unexpected SVG tag: " & child.tag) - ctx.linearGradients[id] = linearGradient + svg.linearGradients[id] = linearGradient else: raise newException(PixieError, "Unsupported SVG tag: " & node.tag) -proc draw(img: Image, node: XmlNode, ctxStack: var seq[Ctx]) = - try: - drawInternal(img, node, ctxStack) - except PixieError as e: - raise e - except: - raise currentExceptionAsPixieError() - -proc decodeSvg*( +proc parseSvg*( data: string | XmlNode, width = 0, height = 0 -): Image {.raises: [PixieError].} = - ## Render SVG XML and return the image. Defaults to the SVG's view box size. +): Svg {.raises: [PixieError].} = + ## Parse SVG XML. Defaults to the SVG's view box size. try: let root = parseXml(data) if root.tag != "svg": @@ -577,27 +516,81 @@ proc decodeSvg*( viewBoxWidth = parseInt(box[2]) viewBoxHeight = parseInt(box[3]) - var rootCtx = initCtx() - rootCtx = decodeCtx(rootCtx, root) + var rootProps = initSvgProperties() + rootProps = root.parseSvgProperties(rootProps) + if viewBoxMinX != 0 or viewBoxMinY != 0: let viewBoxMin = vec2(-viewBoxMinX.float32, -viewBoxMinY.float32) - rootCtx.transform = rootCtx.transform * translate(viewBoxMin) + rootprops.transform = rootprops.transform * translate(viewBoxMin) + + result = Svg() if width == 0 and height == 0: # Default to the view box size - result = newImage(viewBoxWidth, viewBoxHeight) + result.width = viewBoxWidth + result.height = viewBoxHeight else: - result = newImage(width, height) + result.width = width + result.height = height let scaleX = width.float32 / viewBoxWidth.float32 scaleY = height.float32 / viewBoxHeight.float32 - rootCtx.transform = rootCtx.transform * scale(vec2(scaleX, scaleY)) + rootprops.transform = rootprops.transform * scale(vec2(scaleX, scaleY)) - var ctxStack = @[rootCtx] + var propertiesStack = @[rootProps] for node in root.items: - result.draw(node, ctxStack) + result.elements.add node.parseSvgElement(result, propertiesStack) except PixieError as e: raise e except: - raise newException(PixieError, "Unable to load SVG") + raise currentExceptionAsPixieError() + +proc newImage*(svg: Svg): Image {.raises: [PixieError].} = + ## Render SVG and return the image. + result = newImage(svg.width, svg.height) + + try: + for (path, props) in svg.elements: + if props.display and props.opacity > 0: + if props.fill != "none": + var paint: Paint + if props.fill.startsWith("url("): + let closingParen = props.fill.find(")", 5) + if closingParen == -1: + raise newException(PixieError, "Malformed fill: " & props.fill) + let id = props.fill[5 .. closingParen - 1] + if id in svg.linearGradients: + let linearGradient = svg.linearGradients[id] + paint = newPaint(LinearGradientPaint) + paint.gradientHandlePositions = @[ + props.transform * vec2(linearGradient.x1, linearGradient.y1), + props.transform * vec2(linearGradient.x2, linearGradient.y2) + ] + paint.gradientStops = linearGradient.stops + else: + raise newException(PixieError, "Missing SVG resource " & id) + else: + paint = parseHtmlColor(props.fill).rgbx + + paint.opacity = props.fillOpacity * props.opacity + + result.fillPath(path, paint, props.transform, props.fillRule) + + if props.stroke != rgbx(0, 0, 0, 0) and props.strokeWidth > 0: + let paint = newPaint(props.stroke) + paint.color.a *= (props.opacity * props.strokeOpacity) + result.strokePath( + path, + paint, + props.transform, + props.strokeWidth, + props.strokeLineCap, + props.strokeLineJoin, + miterLimit = props.strokeMiterLimit, + dashes = props.strokeDashArray + ) + except PixieError as e: + raise e + except: + raise currentExceptionAsPixieError() diff --git a/src/pixie/paths.nim b/src/pixie/paths.nim index cc95c20..ce37c1c 100644 --- a/src/pixie/paths.nim +++ b/src/pixie/paths.nim @@ -637,11 +637,12 @@ proc polygon*( if sides <= 2: raise newException(PixieError, "Invalid polygon sides value") path.moveTo(x + size * sin(0.0), y - size * cos(0.0)) - for side in 1 .. sides: + for side in 1 .. sides - 1: path.lineTo( x + size * sin(side.float32 * 2.0 * PI / sides.float32), y - size * cos(side.float32 * 2.0 * PI / sides.float32) ) + path.closePath() proc polygon*( path: Path, pos: Vec2, size: float32, sides: int diff --git a/tests/benchmark_jpeg.nim b/tests/benchmark_jpeg.nim index 06d74f5..436134b 100644 --- a/tests/benchmark_jpeg.nim +++ b/tests/benchmark_jpeg.nim @@ -1,6 +1,6 @@ -import benchy, jpegsuite, pixie/fileformats/jpg, strformat +import benchy, jpegsuite, pixie/fileformats/jpeg, strformat for file in jpegSuiteFiles: let data = readFile(file) timeIt &"jpeg {(data.len div 1024)}k decode": - discard decodeJpg(data) + discard decodeJpeg(data) diff --git a/tests/benchmark_svg.nim b/tests/benchmark_svg.nim index c158d6d..cde9a8a 100644 --- a/tests/benchmark_svg.nim +++ b/tests/benchmark_svg.nim @@ -2,5 +2,5 @@ import benchy, pixie/fileformats/svg let data = readFile("tests/fileformats/svg/Ghostscript_Tiger.svg") -timeIt "svg decode": - discard decodeSvg(data) +timeIt "svg parse + render": + discard newImage(parseSvg(data)) diff --git a/tests/fileformats/svg/emojitwo.png b/tests/fileformats/svg/emojitwo.png index 9ff9e7b..6f0f4eb 100644 Binary files a/tests/fileformats/svg/emojitwo.png and b/tests/fileformats/svg/emojitwo.png differ diff --git a/tests/fileformats/svg/noto-emoji.png b/tests/fileformats/svg/noto-emoji.png index 2960dcd..f560eab 100644 Binary files a/tests/fileformats/svg/noto-emoji.png and b/tests/fileformats/svg/noto-emoji.png differ diff --git a/tests/fileformats/svg/openmoji.png b/tests/fileformats/svg/openmoji.png index 1669e25..28f6205 100644 Binary files a/tests/fileformats/svg/openmoji.png and b/tests/fileformats/svg/openmoji.png differ diff --git a/tests/fileformats/svg/simple-icons.png b/tests/fileformats/svg/simple-icons.png index c240a57..2fbb7ed 100644 Binary files a/tests/fileformats/svg/simple-icons.png and b/tests/fileformats/svg/simple-icons.png differ diff --git a/tests/fileformats/svg/twbs-icons.png b/tests/fileformats/svg/twbs-icons.png index cc31391..36c8083 100644 Binary files a/tests/fileformats/svg/twbs-icons.png and b/tests/fileformats/svg/twbs-icons.png differ diff --git a/tests/fileformats/svg/twemoji.png b/tests/fileformats/svg/twemoji.png index d4483dc..15d975b 100644 Binary files a/tests/fileformats/svg/twemoji.png and b/tests/fileformats/svg/twemoji.png differ diff --git a/tests/megatest_emoji.nim b/tests/megatest_emoji.nim index 1e8d69d..7b594de 100644 --- a/tests/megatest_emoji.nim +++ b/tests/megatest_emoji.nim @@ -35,7 +35,7 @@ proc renderEmojiSet(index: int) = let (_, name, _) = splitFile(filePath) var image: Image try: - image = decodeSvg(readFile(filePath), width, height) + image = newImage(parseSvg(readFile(filePath), width, height)) except PixieError: echo &"Failed decoding {name}" image = newImage(width, height) diff --git a/tests/megatest_icons.nim b/tests/megatest_icons.nim index 185a6ec..bd8b838 100644 --- a/tests/megatest_icons.nim +++ b/tests/megatest_icons.nim @@ -38,7 +38,7 @@ proc renderIconSet(index: int) = for filePath in walkFiles(iconSet.path): let (_, name, _) = splitFile(filePath) - image = decodeSvg(readFile(filePath), width, height) + image = newImage(parseSvg(readFile(filePath), width, height)) images.add((name, image)) diff --git a/tests/test_paints.nim b/tests/test_paints.nim index f0a800a..8a347c4 100644 --- a/tests/test_paints.nim +++ b/tests/test_paints.nim @@ -1,4 +1,4 @@ -import chroma, pixie, pixie/fileformats/png, vmath +import chroma, pixie, vmath const heartShape = """ M 10,30 @@ -18,7 +18,7 @@ block: block: let paint = newPaint(ImagePaint) - paint.image = decodePng(readFile("tests/fileformats/png/mandrill.png")) + paint.image = readImage("tests/fileformats/png/mandrill.png") paint.imageMat = scale(vec2(0.2, 0.2)) let image = newImage(100, 100) @@ -27,7 +27,7 @@ block: block: let paint = newPaint(ImagePaint) - paint.image = decodePng(readFile("tests/fileformats/png/mandrill.png")) + paint.image = readImage("tests/fileformats/png/mandrill.png") paint.imageMat = scale(vec2(0.2, 0.2)) paint.opacity = 0.5 @@ -37,7 +37,7 @@ block: block: let paint = newPaint(TiledImagePaint) - paint.image = decodePng(readFile("tests/fileformats/png/mandrill.png")) + paint.image = readImage("tests/fileformats/png/mandrill.png") paint.imageMat = scale(vec2(0.02, 0.02)) let image = newImage(100, 100) @@ -46,7 +46,7 @@ block: block: let paint = newPaint(TiledImagePaint) - paint.image = decodePng(readFile("tests/fileformats/png/mandrill.png")) + paint.image = readImage("tests/fileformats/png/mandrill.png") paint.imageMat = scale(vec2(0.02, 0.02)) paint.opacity = 0.5 diff --git a/tests/test_qoi.nim b/tests/test_qoi.nim index 88b9ff3..b8acd0d 100644 --- a/tests/test_qoi.nim +++ b/tests/test_qoi.nim @@ -1,17 +1,17 @@ -import pixie, pixie/fileformats/png, pixie/fileformats/qoi +import pixie, pixie/fileformats/qoi const tests = ["testcard", "testcard_rgba"] for name in tests: let - input = decodeQoi(readFile("tests/fileformats/qoi/" & name & ".qoi")) - control = decodePng(readFile("tests/fileformats/qoi/" & name & ".png")) + input = readImage("tests/fileformats/qoi/" & name & ".qoi") + control = readImage("tests/fileformats/qoi/" & name & ".png") doAssert input.data == control.data, "input mismatch of " & name discard encodeQoi(control) for name in tests: let - input = decodeQoiRaw(readFile("tests/fileformats/qoi/" & name & ".qoi")) - output = decodeQoiRaw(encodeQoi(input)) + input = decodeQoi(readFile("tests/fileformats/qoi/" & name & ".qoi")) + output = decodeQoi(encodeQoi(input)) doAssert output.data.len == input.data.len doAssert output.data == input.data diff --git a/tests/test_svg.nim b/tests/test_svg.nim index c9f6d43..e31e449 100644 --- a/tests/test_svg.nim +++ b/tests/test_svg.nim @@ -26,9 +26,8 @@ proc doDiff(rendered: Image, name: string) = diffImage.writeFile(&"tests/fileformats/svg/diffs/{name}.png") for file in files: - doDiff(decodeSvg(readFile(&"tests/fileformats/svg/{file}.svg")), file) + doDiff(readImage(&"tests/fileformats/svg/{file}.svg"), file) -doDiff( - decodeSvg(readFile("tests/fileformats/svg/accessibility-outline.svg"), 512, 512), - "accessibility-outline" -) +block: + let svg = parseSvg(readFile("tests/fileformats/svg/accessibility-outline.svg"), 512, 512) + doDiff(newImage(svg), "accessibility-outline")