diff --git a/README.md b/README.md index 04ef3fe..9f9292c 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,14 @@ The `state` engine is the extreme end of this: **stdlib-only**, with no injected IO at all. The IO modules carry the heavier seams via injection, but follow the same rule. Defaults are no-ops, nothing third-party is forced on the consumer. +This "stdlib-only" guarantee is about the library you import: the `state` engine +and its seams pull in nothing third-party. The standalone `crucible` CLI is a +leaf tool, not a library, and is the one exception — it embeds a pure-Go +(WebAssembly) Graphviz **only** for `render -format svg|png`, so you can render +images without installing Graphviz. That convenience lives entirely in the CLI +binary; it never enters the `state` engine or any module you import. See +[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) for attribution. + ## Documentation Guides, concepts, the food-delivery example, and the generated API reference live diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..fba27d9 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,78 @@ +# Third-Party Notices + +The `crucible` command-line tool (`cmd/crucible`) embeds third-party software so +that `crucible render -format svg|png` can produce images without requiring an +external Graphviz installation. The crucible `state` engine and its seams do +**not** depend on any of the software listed here; these notices apply only to +the CLI binary. + +This file is informational. It is **not** a substitute for the licenses shipped +with each dependency, which remain authoritative. + +--- + +## github.com/goccy/go-graphviz + +Pure-Go bindings that run Graphviz compiled to WebAssembly via +[wazero](https://github.com/tetratelabs/wazero). Used by the CLI to render DOT +to SVG/PNG in-process. + +- License: MIT +- Project: https://github.com/goccy/go-graphviz + +``` +MIT License + +Copyright (c) 2020 Masaaki Goshima + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +--- + +## Graphviz (bundled as WebAssembly by go-graphviz) + +`go-graphviz` embeds the [Graphviz](https://graphviz.org/) graph-layout and +rendering engine compiled to WebAssembly. The distributed `crucible` binary +therefore contains Graphviz. + +- License: Eclipse Public License, Version 1.0 (EPL-1.0) +- Project: https://graphviz.org/ +- License text: https://graphviz.org/license/ (and the `LICENSE` shipped within + the go-graphviz module under `vendor`/embedded WebAssembly assets) + +Graphviz is distributed under the Eclipse Public License, Version 1.0. The full +license text is available at https://www.eclipse.org/legal/epl-v10.html. The +program source for the embedded Graphviz is available from the Graphviz project +at https://gitlab.com/graphviz/graphviz. + +--- + +## Transitive image-encoding dependencies + +`go-graphviz` pulls in pure-Go image-encoding libraries used during PNG/JPEG +rendering. Their licenses are reproduced in their respective module directories +in the Go module cache and in `cmd/crucible/go.sum`: + +- `github.com/tetratelabs/wazero` — Apache License 2.0 (WebAssembly runtime) +- `github.com/disintegration/imaging` — MIT +- `github.com/fogleman/gg` — MIT +- `github.com/golang/freetype` — FreeType License / GNU GPL (dual) +- `github.com/flopp/go-findfont` — MIT +- `golang.org/x/image`, `golang.org/x/text` — BSD-3-Clause diff --git a/cmd/crucible/CHANGELOG.md b/cmd/crucible/CHANGELOG.md index 2a2ab9b..dac0590 100644 --- a/cmd/crucible/CHANGELOG.md +++ b/cmd/crucible/CHANGELOG.md @@ -9,6 +9,11 @@ versioned independently of the `state` module. ### Added +- `render -format svg|png` renders the machine directly to a themed image via an + embedded, pure-Go (WebAssembly) Graphviz — no external Graphviz install is + required. The image carries the Crucible brand palette. `-o file` writes the + output to a file instead of stdout (the norm for binary `png`); `mermaid` and + `dot` output is unchanged. - `lint -format` selects the output format: `text` (default), `json`, or `sarif` (SARIF 2.1.0) for machine-readable CI ingestion. - `diff -format` selects `text` (default) or `json` output. diff --git a/cmd/crucible/README.md b/cmd/crucible/README.md index 6a5b728..729c5ed 100644 --- a/cmd/crucible/README.md +++ b/cmd/crucible/README.md @@ -32,12 +32,18 @@ physical location unless the IR was read from stdin (`-`). ### render ``` -crucible render [-format mermaid|dot] +crucible render [-format mermaid|dot|svg|png] [-o outfile] ``` -Renders the machine as a Mermaid `stateDiagram-v2` (the default) or as Graphviz -DOT. Output is text. For an SVG, pipe the DOT through Graphviz (`crucible render -m.json -format dot | dot -Tsvg`); native SVG rendering is a future addition. +Renders the machine as a Mermaid `stateDiagram-v2` (the default), Graphviz DOT, +or a themed `svg`/`png` image. The `svg` and `png` formats are rendered directly +by an embedded, pure-Go (WebAssembly) Graphviz, so **no external Graphviz +install is required** and the image carries the Crucible brand palette. + +`-o` writes the output to a file instead of stdout; it is the norm for `png`, +whose bytes are binary. `mermaid`/`dot` remain text and stream to stdout +unchanged (the historical `crucible render m.json -format dot | dot -Tsvg` +pipeline still works for callers who prefer their own Graphviz). ### diff diff --git a/cmd/crucible/cmd.go b/cmd/crucible/cmd.go index b6258d5..a561753 100644 --- a/cmd/crucible/cmd.go +++ b/cmd/crucible/cmd.go @@ -6,6 +6,7 @@ import ( "os" "github.com/stablekernel/crucible/gen" + "github.com/stablekernel/crucible/state" "github.com/stablekernel/crucible/state/analysis" "github.com/stablekernel/crucible/state/evolution" ) @@ -59,22 +60,28 @@ func runLint(args []string, stdout, stderr io.Writer) int { return exitOK } -// runRender loads an IR, assembles it with stub behaviors, and prints the -// machine diagram. -format selects mermaid (the default) or dot. SVG output is -// not produced here; pipe the dot text through Graphviz for an image. +// runRender loads an IR, assembles it with stub behaviors, and emits the +// machine diagram. -format selects mermaid (the default), dot, svg, or png. The +// svg and png formats are rendered directly via an embedded (pure-Go, WASM) +// Graphviz — no external `dot` install is required — and carry the Crucible +// brand theme. Image bytes go to -o when set, otherwise to stdout; png in +// particular is binary, so -o is the norm. func runRender(args []string, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("render", flag.ContinueOnError) fs.SetOutput(stderr) - format := fs.String("format", "mermaid", "diagram format: mermaid or dot") + format := fs.String("format", "mermaid", "diagram format: mermaid, dot, svg, or png") + out := fs.String("o", "", "output file (default: stdout)") if err := fs.Parse(reorderArgs(args)); err != nil { return exitUsage } if fs.NArg() != 1 { - emitln(stderr, "usage: crucible render [-format mermaid|dot]") + emitln(stderr, "usage: crucible render [-format mermaid|dot|svg|png] [-o outfile]") return exitUsage } - if *format != "mermaid" && *format != "dot" { - emitf(stderr, "crucible render: unknown -format %q (want mermaid or dot)\n", *format) + switch *format { + case "mermaid", "dot", "svg", "png": + default: + emitf(stderr, "crucible render: unknown -format %q (want mermaid, dot, svg, or png)\n", *format) return exitUsage } @@ -90,6 +97,10 @@ func runRender(args []string, stdout, stderr io.Writer) int { } switch *format { + case "svg": + return renderImageToOutput(m, formatSVG, *out, stdout, stderr) + case "png": + return renderImageToOutput(m, formatPNG, *out, stdout, stderr) case "dot": emit(stdout, m.ToDOT()) default: @@ -98,6 +109,28 @@ func runRender(args []string, stdout, stderr io.Writer) int { return exitOK } +// renderImageToOutput renders the machine to image bytes and writes them either +// to the named file or to stdout. The bytes are binary, so they bypass the +// emit* helpers (which append newlines and would corrupt a PNG). It returns the +// process exit code. +func renderImageToOutput[S comparable, E comparable, C any](m *state.Machine[S, E, C], format imageFormat, out string, stdout, stderr io.Writer) int { + img, err := renderImage(m, format) + if err != nil { + emitf(stderr, "crucible render: %v\n", err) + return exitError + } + if out == "" { + _, err = stdout.Write(img) + } else { + err = os.WriteFile(out, img, 0o644) + } + if err != nil { + emitf(stderr, "crucible render: write output: %v\n", err) + return exitError + } + return exitOK +} + // runDiff loads two serialized IRs, classifies the changes between them, and // prints the recommended semver bump along with the breaking and additive // changes split apart. diff --git a/cmd/crucible/cmd_test.go b/cmd/crucible/cmd_test.go index f592eed..d000653 100644 --- a/cmd/crucible/cmd_test.go +++ b/cmd/crucible/cmd_test.go @@ -42,12 +42,18 @@ func TestRender(t *testing.T) { } func TestRender_UnknownFormat(t *testing.T) { - code, _, errOut := runCmd("render", "testdata/clean.json", "-format", "svg") - if code != exitUsage { - t.Fatalf("exit = %d, want %d", code, exitUsage) - } - if !strings.Contains(errOut, "unknown -format") { - t.Fatalf("stderr missing format error: %s", errOut) + // jpg is explicitly out of scope and zzz is nonsense; both are usage errors. + // svg/png are now valid (see render_image_test.go). + for _, format := range []string{"jpg", "zzz"} { + t.Run(format, func(t *testing.T) { + code, _, errOut := runCmd("render", "testdata/clean.json", "-format", format) + if code != exitUsage { + t.Fatalf("exit = %d, want %d", code, exitUsage) + } + if !strings.Contains(errOut, "unknown -format") { + t.Fatalf("stderr missing format error: %s", errOut) + } + }) } } diff --git a/cmd/crucible/go.mod b/cmd/crucible/go.mod index 567aad5..edd8797 100644 --- a/cmd/crucible/go.mod +++ b/cmd/crucible/go.mod @@ -1,8 +1,20 @@ module github.com/stablekernel/crucible/cmd/crucible -go 1.25 +go 1.25.0 require ( + github.com/goccy/go-graphviz v0.2.10 github.com/stablekernel/crucible/gen v0.1.0 github.com/stablekernel/crucible/state v1.0.0 ) + +require ( + github.com/disintegration/imaging v1.6.2 // indirect + github.com/flopp/go-findfont v0.1.0 // indirect + github.com/fogleman/gg v1.3.0 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect + github.com/tetratelabs/wazero v1.12.0 // indirect + golang.org/x/image v0.21.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.35.0 // indirect +) diff --git a/cmd/crucible/go.sum b/cmd/crucible/go.sum index c8defa2..cb827d9 100644 --- a/cmd/crucible/go.sum +++ b/cmd/crucible/go.sum @@ -1,4 +1,28 @@ +github.com/corona10/goimagehash v1.1.0 h1:teNMX/1e+Wn/AYSbLHX8mj+mF9r60R1kBeqE9MkoYwI= +github.com/corona10/goimagehash v1.1.0/go.mod h1:VkvE0mLn84L4aF8vCb6mafVajEb6QYMHl2ZJLn0mOGI= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/flopp/go-findfont v0.1.0 h1:lPn0BymDUtJo+ZkV01VS3661HL6F4qFlkhcJN55u6mU= +github.com/flopp/go-findfont v0.1.0/go.mod h1:wKKxRDjD024Rh7VMwoU90i6ikQRCr+JTHB5n4Ejkqvw= +github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= +github.com/goccy/go-graphviz v0.2.10 h1:jHu/1I0Iw0xIzzYk96Ous/ZeuD11Rt2oW8juHdIE30g= +github.com/goccy/go-graphviz v0.2.10/go.mod h1:LRlMnNmY17QbN6fLnvOzY7g0rXQjLKAhzxeTHbEUM6w= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= +github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/stablekernel/crucible/gen v0.1.0 h1:IdQlLIsoIYMlUgWeZiY3pzTBvxe5wBZhpPaZ02xW/NY= github.com/stablekernel/crucible/gen v0.1.0/go.mod h1:rl6qLJ21rp9B/nMYiAM0sbSgi+2wE0NXP+7+p/rc1RE= github.com/stablekernel/crucible/state v1.0.0 h1:YjhEM3vqqHptq33j+zZlYgSa4FupAFrDgAUUPfLwx68= github.com/stablekernel/crucible/state v1.0.0/go.mod h1:GU2LNVI+FJrdii+UNZYqfwcH219dCKO+TkX6KRE/Fys= +github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU= +github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s= +golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= diff --git a/cmd/crucible/main.go b/cmd/crucible/main.go index a08691a..4cab86f 100644 --- a/cmd/crucible/main.go +++ b/cmd/crucible/main.go @@ -85,7 +85,7 @@ Usage: Commands: lint [-format f] run static analysis; -format text (default), json, or sarif - render [-format f] render the machine as mermaid (default) or dot + render [-format f] [-o file] render as mermaid (default), dot, svg, or png (svg/png embed Graphviz; no external install) diff [-format f] [-exit-code] classify changes and recommend a semver bump validate confirm the IR loads and assembles eject [-package p] [-o f] generate typed Go behavior stubs diff --git a/cmd/crucible/render_image.go b/cmd/crucible/render_image.go new file mode 100644 index 0000000..e3d4c1b --- /dev/null +++ b/cmd/crucible/render_image.go @@ -0,0 +1,110 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "strings" + + "github.com/goccy/go-graphviz" + + "github.com/stablekernel/crucible/state" +) + +// Crucible brand palette applied to rendered images. These are Graphviz DOT +// default attributes (ember borders, charcoal text, copper edges) injected by +// themeDOT; the state package emits the structural, brand-agnostic DOT. +const ( + brandEmber = "#d9620a" // node border + brandCharcoal = "#16191d" // node/edge text + brandCopper = "#b06a28" // edge color + brandBackgound = "transparent" +) + +// themeDOT applies the Crucible brand to brand-agnostic DOT produced by +// state.Machine.ToDOT. It inserts graph/node/edge default attributes +// immediately after the opening "digraph ... {" line, so per-element +// attributes that state already emits (node fillcolor for ownership, +// peripheries for final states, per-edge styling) still win — DOT defaults +// apply only where an element does not override them. The brand chrome is +// therefore additive: ownership fills and final-state rings are preserved. +// +// If the expected "digraph ... {" header is not found (an unexpected DOT +// shape), the input is returned unchanged so rendering still succeeds with the +// untouched, structurally valid DOT. +func themeDOT(dot string) string { + const open = "{\n" + headerEnd := strings.Index(dot, open) + if !strings.HasPrefix(dot, "digraph ") || headerEnd < 0 { + return dot + } + insertAt := headerEnd + len(open) + + var b strings.Builder + b.Grow(len(dot) + 160) + b.WriteString(dot[:insertAt]) + fmt.Fprintf(&b, " bgcolor=%q\n", brandBackgound) + fmt.Fprintf(&b, " node [color=%q fontcolor=%q]\n", brandEmber, brandCharcoal) + fmt.Fprintf(&b, " edge [color=%q fontcolor=%q]\n", brandCopper, brandCharcoal) + b.WriteString(dot[insertAt:]) + return b.String() +} + +// imageFormat selects the raster/vector encoding produced by renderImage. +type imageFormat int + +const ( + formatSVG imageFormat = iota + formatPNG +) + +// renderImage renders a quenched machine to themed SVG or PNG bytes via the +// embedded (pure-Go, WASM) Graphviz. The machine emits brand-agnostic DOT, +// which themeDOT decorates with the Crucible palette before parsing so the +// image carries the project brand; the bytes are binary and MUST be written +// verbatim (never through the emit* helpers, which append newlines and would +// corrupt a PNG). +// +// A fresh Graphviz instance spins up a wazero WASM runtime per call. That is +// heavy, but a CLI renders once per invocation, so the cost is paid once and the +// runtime is torn down on return. +func renderImage[S comparable, E comparable, C any](m *state.Machine[S, E, C], format imageFormat) ([]byte, error) { + dot := themeDOT(m.ToDOT()) + + ctx := context.Background() + g, err := graphviz.New(ctx) + if err != nil { + return nil, fmt.Errorf("init graphviz: %w", err) + } + defer func() { _ = g.Close() }() + + graph, err := graphviz.ParseBytes([]byte(dot)) + if err != nil { + return nil, fmt.Errorf("parse dot: %w", err) + } + defer func() { _ = graph.Close() }() + + gvFormat, err := toGraphvizFormat(format) + if err != nil { + return nil, err + } + + var buf bytes.Buffer + if err := g.Render(ctx, graph, gvFormat, &buf); err != nil { + return nil, fmt.Errorf("render %s: %w", gvFormat, err) + } + return buf.Bytes(), nil +} + +// toGraphvizFormat maps the internal image format to a go-graphviz format +// constant. +func toGraphvizFormat(format imageFormat) (graphviz.Format, error) { + switch format { + case formatSVG: + return graphviz.SVG, nil + case formatPNG: + return graphviz.PNG, nil + default: + return "", fmt.Errorf("unsupported image format %d", format) + } +} diff --git a/cmd/crucible/render_image_test.go b/cmd/crucible/render_image_test.go new file mode 100644 index 0000000..4bacb70 --- /dev/null +++ b/cmd/crucible/render_image_test.go @@ -0,0 +1,130 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +// pngMagic is the 8-byte PNG file signature. +var pngMagic = []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'} + +// TestThemeDOT_AddsBrandAndPreservesStructure is the authoritative check that +// the Crucible brand is applied at the DOT level. It asserts the brand hexes +// are injected as graph/node/edge defaults while every structural line of the +// original DOT (nodes, edges, per-element attributes) survives unchanged. +func TestThemeDOT_AddsBrandAndPreservesStructure(t *testing.T) { + const in = "digraph M {\n" + + " rankdir=LR;\n" + + " node [shape=box, style=rounded];\n" + + " \"a\" [fillcolor=\"#abc\", style=filled];\n" + + " \"b\" [peripheries=2];\n" + + " \"a\" -> \"b\" [label=\"go\"];\n" + + "}\n" + + out := themeDOT(in) + + for _, hex := range []string{"#d9620a", "#b06a28", "#16191d"} { + if !strings.Contains(out, hex) { + t.Errorf("themed DOT missing brand hex %q:\n%s", hex, out) + } + } + if !strings.Contains(out, `bgcolor="transparent"`) { + t.Errorf("themed DOT missing transparent bgcolor:\n%s", out) + } + // Brand attrs are DOT defaults, so per-element fills/rings must be intact. + for _, frag := range []string{ + `"a" [fillcolor="#abc", style=filled];`, + `"b" [peripheries=2];`, + `"a" -> "b" [label="go"];`, + } { + if !strings.Contains(out, frag) { + t.Errorf("themed DOT dropped structural fragment %q:\n%s", frag, out) + } + } + // The brand defaults must sit inside the digraph body, after the header. + header := strings.Index(out, "{\n") + for _, def := range []string{"bgcolor=", "node [color=", "edge [color="} { + if idx := strings.Index(out, def); idx < header { + t.Errorf("brand default %q not inserted after digraph header", def) + } + } +} + +// TestThemeDOT_UnexpectedHeaderReturnedUnchanged verifies the graceful +// fallback: DOT lacking the expected "digraph ... {" header is returned +// verbatim so rendering still succeeds with structurally valid input. +func TestThemeDOT_UnexpectedHeaderReturnedUnchanged(t *testing.T) { + const in = "graph G {\n a -- b;\n}\n" + if out := themeDOT(in); out != in { + t.Errorf("expected unchanged DOT for non-digraph input, got:\n%s", out) + } +} + +// runCmdRaw invokes the CLI dispatcher in-process and returns the exit code with +// captured stdout as raw bytes (so binary PNG output survives) plus stderr text. +func runCmdRaw(args ...string) (code int, stdout []byte, stderr string) { + var out, errBuf bytes.Buffer + code = run(args, &out, &errBuf) + return code, out.Bytes(), errBuf.String() +} + +// TestRender_SVG asserts svg renders to stdout: exit 0 and an