From 668979c90bf782d8477280da234ce8a2e852b852 Mon Sep 17 00:00:00 2001 From: Artha Date: Fri, 17 Jan 2025 08:55:07 +0100 Subject: [PATCH] Add dumpDraws and dumpDrawsToSVG to TestSpace --- dub.json | 7 +- source/fluid/backend/headless.d | 39 +--- source/fluid/test_space.d | 313 ++++++++++++++++++++++++++++++++ source/fluid/types.d | 56 ++++-- 4 files changed, 371 insertions(+), 44 deletions(-) diff --git a/dub.json b/dub.json index db89dfe2..2d4d2f3b 100644 --- a/dub.json +++ b/dub.json @@ -26,7 +26,9 @@ }, { "dependencies": { - "silly": "~>1.1.1" + "silly": "~>1.1.1", + "elemi": "~>1.2.2", + "arsd-official:image_files": "~>11.3" }, "importPaths": [ "tests", @@ -43,7 +45,8 @@ "targetType": "library", "versions": [ "Fluid_HeadlessOutput", - "Fluid_TestSpace" + "Fluid_TestSpace", + "Fluid_SVG" ] }, { diff --git a/source/fluid/backend/headless.d b/source/fluid/backend/headless.d index ff58bc87..727c4bb5 100644 --- a/source/fluid/backend/headless.d +++ b/source/fluid/backend/headless.d @@ -1,12 +1,12 @@ -/// A headless backend. This backend does not actually render anything. This allows apps reliant on Fluid to run +/// A headless backend. This backend does not actually render anything. This allows apps reliant on Fluid to run /// outside of graphical environments, provided an alternative method of access exist. /// -/// This backend is used internally in Fluid for performing tests. For this reason, this module may be configured to -/// capture the output in a way that can be analyzed or compared againt later. This functionality is disabled by +/// This backend is used internally in Fluid for performing tests. For this reason, this module may be configured to +/// capture the output in a way that can be analyzed or compared againt later. This functionality is disabled by /// default due to significant overhead — use version `Fluid_HeadlessOutput` to turn it on. /// -/// If `elemi` is added as a dependency and `Fluid_HeadlessOutput` is set, the backend will also expose its -/// experimental SVG export functionality through `saveSVG`. It is only intended for testing; note it will export text +/// If `elemi` is added as a dependency and `Fluid_HeadlessOutput` is set, the backend will also expose its +/// experimental SVG export functionality through `saveSVG`. It is only intended for testing; note it will export text /// as embedded raster images rather than proper vector text. module fluid.backend.headless; @@ -181,7 +181,7 @@ class HeadlessBackend : FluidBackend { } version (Fluid_HeadlessOutput) { - + alias Drawing = SumType!(DrawnLine, DrawnTriangle, DrawnCircle, DrawnRectangle, DrawnTexture); /// All items drawn during the last frame @@ -484,7 +484,7 @@ class HeadlessBackend : FluidBackend { Rectangle area() const { - if (_scissorsOn) + if (_scissorsOn) return _area; else return Rectangle(0, 0, _windowSize.tupleof); @@ -568,30 +568,7 @@ class HeadlessBackend : FluidBackend { import arsd.png; import arsd.image; - ubyte[] data; - - // Load the image - final switch (image.format) { - - case Image.Format.rgba: - data = cast(ubyte[]) image.rgbaPixels; - break; - - // At the moment, this loads the palette available at the time of generation. - // Could it be possible to update the palette later? - case Image.Format.palettedAlpha: - data = cast(ubyte[]) image.palettedAlphaPixels - .map!(a => image.paletteColor(a)) - .array; - break; - - case Image.Format.alpha: - data = cast(ubyte[]) image.alphaPixels - .map!(a => Color(0xff, 0xff, 0xff, a)) - .array; - break; - - } + ubyte[] data = cast(ubyte[]) image.toRGBA.data; // Load the image auto arsdImage = new TrueColorImage(image.width, image.height, data); diff --git a/source/fluid/test_space.d b/source/fluid/test_space.d index d2de3b1d..8b5a221d 100644 --- a/source/fluid/test_space.d +++ b/source/fluid/test_space.d @@ -1208,6 +1208,319 @@ auto drawsWildcard(alias dg)(lazy string message) { } +/// Output every draw instruction to stdout (`dumpDraws`), and, optionally, to an SVG file (`dumpDrawsToSVG`). +/// +/// Note that `dumpDraws` is equivalent to an `isDrawn` assert. It cannot be mixed with any other asserts on the same +/// node. +/// +/// SVG support has to be enabled by passing `Fluid_SVG`. +/// It requires extra dependencies: [elemi](https://code.dlang.org/packages/elemi) +/// and [arsd-official:image_files](https://code.dlang.org/packages/arsd-official%3Aimage_files). +/// To create an SVG image, call `dumpDrawsToSVG`. +/// SVG support is currently incomplete and unstable. Changes can be made to this feature without prior announcement. +/// +/// Params: +/// subject = Subject the output of which should be captured. +/// filename = Path to save the SVG output to. Requires version `Fluid_SVG` to be set, ignored otherwise. +/// Returns: +/// An assert object to pass to `TestSpace.drawAndAssert`. +auto dumpDrawsToSVG(Node subject, string filename = null) { + auto a = dumpDraws(subject); + a.generateSVG = true; + a.svgFilename = filename; + return a; +} + +/// ditto +auto dumpDraws(Node subject) { + + import std.stdio; + + return new class BlackHole!Assert { + + bool generateSVG; + string svgFilename; + + version (Fluid_SVG) { + import elemi.xml; + Element svg; + bool[Color] tints; + } + + version (Fluid_SVG) + Element exportSVG() nothrow @safe { + + return assumeWontThrow( + elems( + Element.XMLDeclaration1_0, + elem!"svg"( + attr("xmlns") = "http://www.w3.org/2000/svg", + attr("version") = "1.1", + svg, + ), + ), + ); + + } + + bool isSubject(Node node) nothrow @trusted { + return node.opEquals(subject).assertNotThrown; + } + + void dump(string fmt, Arguments...)(Node node, Arguments arguments) nothrow @trusted { + if (isSubject(node)) { + writefln!fmt(arguments).assertNotThrown; + } + } + + override bool afterDraw(Node node, Rectangle, Rectangle, Rectangle) nothrow { + + import std.file : write; + + if (isSubject(node)) { + version (Fluid_SVG) { + if (generateSVG && svgFilename !is null) { + assumeWontThrow( + write(svgFilename, exportSVG) + ); + } + } + return true; + } + return false; + } + + override bool cropArea(Node node, Rectangle rectangle) nothrow { + dump!"cropArea(%s)"(node, rectangle); + return false; + } + + override bool resetCropArea(Node node) nothrow { + dump!"resetCropArea()"(node); + return false; + } + + override bool emitSignal(Node node, string text) nothrow { + dump!"emitSignal(%s)"(node, text); + return false; + } + + override bool drawTriangle(Node node, Vector2 a, Vector2 b, Vector2 c, Color color) nothrow { + + if (isSubject(node)) { + dump!"drawTriangle(%s, %s, %s, %s),"(node, a, b, c, color.toHex.assumeWontThrow); + + version (Fluid_SVG) if (generateSVG) { + assumeWontThrow( + svg ~= elem!"polygon"( + attr("points") = [ + toText(a.x, a.y), + toText(b.x, b.y), + toText(c.x, c.y), + ], + attr("fill") = color.toHex, + ), + ); + } + } + + return false; + } + + override bool drawCircle(Node node, Vector2 center, float radius, Color color) nothrow { + + if (isSubject(node)) { + dump!`node.drawsCircle().at(%s).ofRadius(%s).ofColor("%s"),` + (node, center, radius, color.toHex.assumeWontThrow); + + version (Fluid_SVG) if (generateSVG) { + assumeWontThrow( + svg ~= elem!"circle"( + attr("cx") = toText(center.x), + attr("cy") = toText(center.y), + attr("r") = toText(radius), + attr("fill") = color.toHex, + ), + ); + } + } + + return false; + } + + override bool drawCircleOutline(Node node, Vector2 center, float radius, float width, Color color) nothrow { + + if (isSubject(node)) { + dump!`node.drawsCircleOutline().at(%s).ofRadius(%s).ofColor("%s"),` + (node, center, radius, color.toHex.assumeWontThrow); + + version (Fluid_SVG) if (generateSVG) { + assumeWontThrow( + svg ~= elem!"circle"( + attr("cx") = toText(center.x), + attr("cy") = toText(center.y), + attr("r") = toText(radius), + attr("fill") = "none", + attr("stroke") = color.toHex, + attr("stroke-width") = toText(width), + ), + ); + } + } + + return false; + } + + override bool drawRectangle(Node node, Rectangle area, Color color) nothrow { + + if (isSubject(node)) { + dump!`node.drawsRectangle(%s, %s, %s, %s).ofColor("%s"),` + (node, area.tupleof, color.toHex.assumeWontThrow); + + version (Fluid_SVG) if (generateSVG) { + assumeWontThrow( + svg ~= elem!"rect"( + attr("x") = toText(area.x), + attr("y") = toText(area.y), + attr("width") = toText(area.width), + attr("height") = toText(area.height), + attr("fill") = color.toHex, + ), + ); + } + } + + return false; + } + + override bool drawLine(Node node, Vector2 start, Vector2 end, float width, Color color) nothrow { + + if (isSubject(node)) { + dump!`node.drawsLine().from(%s, %s).to(%s, %s).ofWidth(%s).ofColor("%s"),` + (node, start.tupleof, end.tupleof, width, color.toHex.assumeWontThrow); + + version (Fluid_SVG) if (generateSVG) { + assumeWontThrow( + svg ~= elem!"line"( + attr("x1") = toText(start.x), + attr("y1") = toText(start.y), + attr("x2") = toText(end.x), + attr("y2") = toText(end.y), + attr("stroke") = color.toHex, + attr("stroke-width") = toText(width), + ), + ); + } + } + + return false; + } + + override bool drawImage(Node node, DrawableImage image, Rectangle area, Color color) nothrow { + + if (isSubject(node)) { + dump!`node.drawsImage().at(%s, %s, %s, %s).ofColor("%s"),` + (node, area.tupleof, color.toHex.assumeWontThrow); + svgImage(image, area, color); + } + + return false; + } + + override bool drawHintedImage(Node node, DrawableImage image, Rectangle area, Color color) nothrow { + if (isSubject(node)) { + dump!`node.drawsHintedImage().at(%s, %s, %s, %s).ofColor("%s"),` + (node, area.tupleof, color.toHex.assumeWontThrow); + svgImage(image, area, color); + } + return false; + } + + private void svgImage(DrawableImage image, Rectangle area, Color tint) nothrow @trusted { + + version (Fluid_SVG) if (generateSVG) { + + import std.base64; + import arsd.png; + import arsd.image; + + ubyte[] data = cast(ubyte[]) image.toRGBA.data; + + // Load the image + auto arsdImage = new TrueColorImage(image.width, image.height, data); + + // Encode as a PNG in a data URL + const png = arsdImage.writePngToArray().assumeWontThrow; + const string base64 = Base64.encode(png); + const url = "data:image/png;base64," ~ base64; + + assumeWontThrow( + elems ~= elems( + useTint(tint), + elem!"image"( + attr("x") = toText(area.x), + attr("y") = toText(area.y), + attr("width") = toText(area.width), + attr("height") = toText(area.height), + attr("href") = url, + attr("style") = format!"filter:url(#%s)"(tint.toHex!"t"), + ), + ), + ); + + } + + } + + /// Generate a tint filter for the given color + version (Fluid_SVG) + private Element useTint(Color color) { + + // Ignore if the given filter already exists + if (color in tints) return elems(); + + tints[color] = true; + + // + return elem!"filter"( + + // Use the color as the filter ID, prefixed with "t" instead of "#" + attr("id") = color.toHex!"t", + + // Create a layer full of that color + elem!"feFlood"( + attr("x") = "0", + attr("y") = "0", + attr("width") = "100%", + attr("height") = "100%", + attr("flood-color") = color.toHex, + ), + + // Blend in with the original image + elem!"feBlend"( + attr("in2") = "SourceGraphic", + attr("mode") = "multiply", + ), + + // Use the source image for opacity + elem!"feComposite"( + attr("in2") = "SourceGraphic", + attr("operator") = "in", + ), + + ); + // + + } + + override string toString() const { + return format!"%s must be reached"(subject); + } + + }; + +} + private bool equal(float a, float b) nothrow { const diff = a - b; diff --git a/source/fluid/types.d b/source/fluid/types.d index a586ab33..30663e66 100644 --- a/source/fluid/types.d +++ b/source/fluid/types.d @@ -112,7 +112,7 @@ unittest { } /// Set the alpha channel for the given color, as a float. -Color setAlpha(Color color, float alpha) { +Color setAlpha(Color color, float alpha) pure nothrow { import std.algorithm : clamp; @@ -121,7 +121,7 @@ Color setAlpha(Color color, float alpha) { } -Color setAlpha()(Color color, int alpha) { +Color setAlpha()(Color, int) pure nothrow { static assert(false, "Overload setAlpha(Color, int). Explicitly choose setAlpha(Color, float) (0...1 range) or " ~ "setAlpha(Color, ubyte) (0...255 range)"); @@ -129,7 +129,7 @@ Color setAlpha()(Color color, int alpha) { } /// Set the alpha channel for the given color, as a float. -Color setAlpha(Color color, ubyte alpha) { +Color setAlpha(Color color, ubyte alpha) pure nothrow { color.a = alpha; return color; @@ -269,7 +269,7 @@ struct Image { int revisionNumber; /// Create an RGBA image. - this(Color[] rgbaPixels, int width, int height) nothrow { + this(Color[] rgbaPixels, int width, int height) pure nothrow { this.format = Format.rgba; this.rgbaPixels = rgbaPixels; @@ -279,7 +279,7 @@ struct Image { } /// Create a paletted image. - this(PalettedColor[] palettedAlphaPixels, int width, int height) nothrow { + this(PalettedColor[] palettedAlphaPixels, int width, int height) pure nothrow { this.format = Format.palettedAlpha; this.palettedAlphaPixels = palettedAlphaPixels; @@ -289,7 +289,7 @@ struct Image { } /// Create an alpha mask. - this(ubyte[] alphaPixels, int width, int height) nothrow { + this(ubyte[] alphaPixels, int width, int height) pure nothrow { this.format = Format.alpha; this.alphaPixels = alphaPixels; @@ -298,31 +298,31 @@ struct Image { } - Vector2 size() const { + Vector2 size() const pure nothrow { return Vector2(width, height); } /// Get texture size as a vector. - Vector2 canvasSize() const { + Vector2 canvasSize() const pure nothrow { return Vector2(width, height); } /// Get the size the texture will occupy within the viewport. - Vector2 viewportSize() const { + Vector2 viewportSize() const pure nothrow { return Vector2( width * 96 / dpiX, height * 96 / dpiY ); } - int area() const { + int area() const nothrow { return width * height; } /// Get a palette entry at given index. - Color paletteColor(PalettedColor pixel) const { + Color paletteColor(PalettedColor pixel) const pure nothrow { // Valid index, return the color; Set alpha to match the pixel if (pixel.index < palette.length) @@ -487,6 +487,40 @@ struct Image { } + /// Convert to an RGBA image. + /// + /// Does nothing if the image is already an RGBA image. If it's a paletted image, decodes the colors + /// using currently assigned palette. If it's an alpha mask, fills the image with white. + /// + /// Returns: + /// Self if already in RGBA format, or a newly made image by converting the data. + Image toRGBA() pure nothrow { + + final switch (format) { + + case Format.rgba: + return this; + + // At the moment, this loads the palette available at the time of generation. + // Could it be possible to update the palette later? + case Format.palettedAlpha: + auto colors = new Color[palettedAlphaPixels.length]; + foreach (i, pixel; palettedAlphaPixels) { + colors[i] = paletteColor(pixel); + } + return Image(colors, width, height); + + case Format.alpha: + auto colors = new Color[alphaPixels.length]; + foreach (i, pixel; alphaPixels) { + colors[i] = Color(0xff, 0xff, 0xff, pixel); + } + return Image(colors, width, height); + + } + + } + string toString() const pure { import std.array;