Skip to content

Commit 9e8efd5

Browse files
feat(cmd/crucible): render SVG/PNG via embedded Graphviz with a Forge theme (#185)
render gains -format svg|png (alongside dot|mermaid) and -o. Images render in-process through goccy/go-graphviz (Graphviz compiled to WebAssembly via wazero), so no external Graphviz install is needed. The CLI applies the Forge/Crucible brand (ember borders, copper edges, charcoal text) by injecting DOT default attributes before rendering; ownership fills and final-state rings are preserved. The dependency lives only in cmd/crucible. The state engine is untouched and stays stdlib-only; the README and a new THIRD_PARTY_NOTICES.md scope the "stdlib-only" guarantee to the library and attribute go-graphviz (MIT) and the bundled Graphviz (EPL). Closes #176.
1 parent 8e11e3f commit 9e8efd5

11 files changed

Lines changed: 431 additions & 19 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ The `state` engine is the extreme end of this: **stdlib-only**, with no injected
3333
IO at all. The IO modules carry the heavier seams via injection, but follow the
3434
same rule. Defaults are no-ops, nothing third-party is forced on the consumer.
3535

36+
This "stdlib-only" guarantee is about the library you import: the `state` engine
37+
and its seams pull in nothing third-party. The standalone `crucible` CLI is a
38+
leaf tool, not a library, and is the one exception — it embeds a pure-Go
39+
(WebAssembly) Graphviz **only** for `render -format svg|png`, so you can render
40+
images without installing Graphviz. That convenience lives entirely in the CLI
41+
binary; it never enters the `state` engine or any module you import. See
42+
[`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) for attribution.
43+
3644
## Documentation
3745

3846
Guides, concepts, the food-delivery example, and the generated API reference live

THIRD_PARTY_NOTICES.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Third-Party Notices
2+
3+
The `crucible` command-line tool (`cmd/crucible`) embeds third-party software so
4+
that `crucible render -format svg|png` can produce images without requiring an
5+
external Graphviz installation. The crucible `state` engine and its seams do
6+
**not** depend on any of the software listed here; these notices apply only to
7+
the CLI binary.
8+
9+
This file is informational. It is **not** a substitute for the licenses shipped
10+
with each dependency, which remain authoritative.
11+
12+
---
13+
14+
## github.com/goccy/go-graphviz
15+
16+
Pure-Go bindings that run Graphviz compiled to WebAssembly via
17+
[wazero](https://github.com/tetratelabs/wazero). Used by the CLI to render DOT
18+
to SVG/PNG in-process.
19+
20+
- License: MIT
21+
- Project: https://github.com/goccy/go-graphviz
22+
23+
```
24+
MIT License
25+
26+
Copyright (c) 2020 Masaaki Goshima
27+
28+
Permission is hereby granted, free of charge, to any person obtaining a copy
29+
of this software and associated documentation files (the "Software"), to deal
30+
in the Software without restriction, including without limitation the rights
31+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
32+
copies of the Software, and to permit persons to whom the Software is
33+
furnished to do so, subject to the following conditions:
34+
35+
The above copyright notice and this permission notice shall be included in all
36+
copies or substantial portions of the Software.
37+
38+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
39+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
40+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
41+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
42+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
43+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
44+
SOFTWARE.
45+
```
46+
47+
---
48+
49+
## Graphviz (bundled as WebAssembly by go-graphviz)
50+
51+
`go-graphviz` embeds the [Graphviz](https://graphviz.org/) graph-layout and
52+
rendering engine compiled to WebAssembly. The distributed `crucible` binary
53+
therefore contains Graphviz.
54+
55+
- License: Eclipse Public License, Version 1.0 (EPL-1.0)
56+
- Project: https://graphviz.org/
57+
- License text: https://graphviz.org/license/ (and the `LICENSE` shipped within
58+
the go-graphviz module under `vendor`/embedded WebAssembly assets)
59+
60+
Graphviz is distributed under the Eclipse Public License, Version 1.0. The full
61+
license text is available at https://www.eclipse.org/legal/epl-v10.html. The
62+
program source for the embedded Graphviz is available from the Graphviz project
63+
at https://gitlab.com/graphviz/graphviz.
64+
65+
---
66+
67+
## Transitive image-encoding dependencies
68+
69+
`go-graphviz` pulls in pure-Go image-encoding libraries used during PNG/JPEG
70+
rendering. Their licenses are reproduced in their respective module directories
71+
in the Go module cache and in `cmd/crucible/go.sum`:
72+
73+
- `github.com/tetratelabs/wazero` — Apache License 2.0 (WebAssembly runtime)
74+
- `github.com/disintegration/imaging` — MIT
75+
- `github.com/fogleman/gg` — MIT
76+
- `github.com/golang/freetype` — FreeType License / GNU GPL (dual)
77+
- `github.com/flopp/go-findfont` — MIT
78+
- `golang.org/x/image`, `golang.org/x/text` — BSD-3-Clause

cmd/crucible/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ versioned independently of the `state` module.
99

1010
### Added
1111

12+
- `render -format svg|png` renders the machine directly to a themed image via an
13+
embedded, pure-Go (WebAssembly) Graphviz — no external Graphviz install is
14+
required. The image carries the Crucible brand palette. `-o file` writes the
15+
output to a file instead of stdout (the norm for binary `png`); `mermaid` and
16+
`dot` output is unchanged.
1217
- `lint -format` selects the output format: `text` (default), `json`, or
1318
`sarif` (SARIF 2.1.0) for machine-readable CI ingestion.
1419
- `diff -format` selects `text` (default) or `json` output.

cmd/crucible/README.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,18 @@ physical location unless the IR was read from stdin (`-`).
3232
### render
3333

3434
```
35-
crucible render <ir.json> [-format mermaid|dot]
35+
crucible render <ir.json> [-format mermaid|dot|svg|png] [-o outfile]
3636
```
3737

38-
Renders the machine as a Mermaid `stateDiagram-v2` (the default) or as Graphviz
39-
DOT. Output is text. For an SVG, pipe the DOT through Graphviz (`crucible render
40-
m.json -format dot | dot -Tsvg`); native SVG rendering is a future addition.
38+
Renders the machine as a Mermaid `stateDiagram-v2` (the default), Graphviz DOT,
39+
or a themed `svg`/`png` image. The `svg` and `png` formats are rendered directly
40+
by an embedded, pure-Go (WebAssembly) Graphviz, so **no external Graphviz
41+
install is required** and the image carries the Crucible brand palette.
42+
43+
`-o` writes the output to a file instead of stdout; it is the norm for `png`,
44+
whose bytes are binary. `mermaid`/`dot` remain text and stream to stdout
45+
unchanged (the historical `crucible render m.json -format dot | dot -Tsvg`
46+
pipeline still works for callers who prefer their own Graphviz).
4147

4248
### diff
4349

cmd/crucible/cmd.go

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"os"
77

88
"github.com/stablekernel/crucible/gen"
9+
"github.com/stablekernel/crucible/state"
910
"github.com/stablekernel/crucible/state/analysis"
1011
"github.com/stablekernel/crucible/state/evolution"
1112
)
@@ -59,22 +60,28 @@ func runLint(args []string, stdout, stderr io.Writer) int {
5960
return exitOK
6061
}
6162

62-
// runRender loads an IR, assembles it with stub behaviors, and prints the
63-
// machine diagram. -format selects mermaid (the default) or dot. SVG output is
64-
// not produced here; pipe the dot text through Graphviz for an image.
63+
// runRender loads an IR, assembles it with stub behaviors, and emits the
64+
// machine diagram. -format selects mermaid (the default), dot, svg, or png. The
65+
// svg and png formats are rendered directly via an embedded (pure-Go, WASM)
66+
// Graphviz — no external `dot` install is required — and carry the Crucible
67+
// brand theme. Image bytes go to -o when set, otherwise to stdout; png in
68+
// particular is binary, so -o is the norm.
6569
func runRender(args []string, stdout, stderr io.Writer) int {
6670
fs := flag.NewFlagSet("render", flag.ContinueOnError)
6771
fs.SetOutput(stderr)
68-
format := fs.String("format", "mermaid", "diagram format: mermaid or dot")
72+
format := fs.String("format", "mermaid", "diagram format: mermaid, dot, svg, or png")
73+
out := fs.String("o", "", "output file (default: stdout)")
6974
if err := fs.Parse(reorderArgs(args)); err != nil {
7075
return exitUsage
7176
}
7277
if fs.NArg() != 1 {
73-
emitln(stderr, "usage: crucible render <ir.json> [-format mermaid|dot]")
78+
emitln(stderr, "usage: crucible render <ir.json> [-format mermaid|dot|svg|png] [-o outfile]")
7479
return exitUsage
7580
}
76-
if *format != "mermaid" && *format != "dot" {
77-
emitf(stderr, "crucible render: unknown -format %q (want mermaid or dot)\n", *format)
81+
switch *format {
82+
case "mermaid", "dot", "svg", "png":
83+
default:
84+
emitf(stderr, "crucible render: unknown -format %q (want mermaid, dot, svg, or png)\n", *format)
7885
return exitUsage
7986
}
8087

@@ -90,6 +97,10 @@ func runRender(args []string, stdout, stderr io.Writer) int {
9097
}
9198

9299
switch *format {
100+
case "svg":
101+
return renderImageToOutput(m, formatSVG, *out, stdout, stderr)
102+
case "png":
103+
return renderImageToOutput(m, formatPNG, *out, stdout, stderr)
93104
case "dot":
94105
emit(stdout, m.ToDOT())
95106
default:
@@ -98,6 +109,28 @@ func runRender(args []string, stdout, stderr io.Writer) int {
98109
return exitOK
99110
}
100111

112+
// renderImageToOutput renders the machine to image bytes and writes them either
113+
// to the named file or to stdout. The bytes are binary, so they bypass the
114+
// emit* helpers (which append newlines and would corrupt a PNG). It returns the
115+
// process exit code.
116+
func renderImageToOutput[S comparable, E comparable, C any](m *state.Machine[S, E, C], format imageFormat, out string, stdout, stderr io.Writer) int {
117+
img, err := renderImage(m, format)
118+
if err != nil {
119+
emitf(stderr, "crucible render: %v\n", err)
120+
return exitError
121+
}
122+
if out == "" {
123+
_, err = stdout.Write(img)
124+
} else {
125+
err = os.WriteFile(out, img, 0o644)
126+
}
127+
if err != nil {
128+
emitf(stderr, "crucible render: write output: %v\n", err)
129+
return exitError
130+
}
131+
return exitOK
132+
}
133+
101134
// runDiff loads two serialized IRs, classifies the changes between them, and
102135
// prints the recommended semver bump along with the breaking and additive
103136
// changes split apart.

cmd/crucible/cmd_test.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,18 @@ func TestRender(t *testing.T) {
4242
}
4343

4444
func TestRender_UnknownFormat(t *testing.T) {
45-
code, _, errOut := runCmd("render", "testdata/clean.json", "-format", "svg")
46-
if code != exitUsage {
47-
t.Fatalf("exit = %d, want %d", code, exitUsage)
48-
}
49-
if !strings.Contains(errOut, "unknown -format") {
50-
t.Fatalf("stderr missing format error: %s", errOut)
45+
// jpg is explicitly out of scope and zzz is nonsense; both are usage errors.
46+
// svg/png are now valid (see render_image_test.go).
47+
for _, format := range []string{"jpg", "zzz"} {
48+
t.Run(format, func(t *testing.T) {
49+
code, _, errOut := runCmd("render", "testdata/clean.json", "-format", format)
50+
if code != exitUsage {
51+
t.Fatalf("exit = %d, want %d", code, exitUsage)
52+
}
53+
if !strings.Contains(errOut, "unknown -format") {
54+
t.Fatalf("stderr missing format error: %s", errOut)
55+
}
56+
})
5157
}
5258
}
5359

cmd/crucible/go.mod

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
11
module github.com/stablekernel/crucible/cmd/crucible
22

3-
go 1.25
3+
go 1.25.0
44

55
require (
6+
github.com/goccy/go-graphviz v0.2.10
67
github.com/stablekernel/crucible/gen v0.1.0
78
github.com/stablekernel/crucible/state v1.0.0
89
)
10+
11+
require (
12+
github.com/disintegration/imaging v1.6.2 // indirect
13+
github.com/flopp/go-findfont v0.1.0 // indirect
14+
github.com/fogleman/gg v1.3.0 // indirect
15+
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
16+
github.com/tetratelabs/wazero v1.12.0 // indirect
17+
golang.org/x/image v0.21.0 // indirect
18+
golang.org/x/sys v0.45.0 // indirect
19+
golang.org/x/text v0.35.0 // indirect
20+
)

cmd/crucible/go.sum

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,28 @@
1+
github.com/corona10/goimagehash v1.1.0 h1:teNMX/1e+Wn/AYSbLHX8mj+mF9r60R1kBeqE9MkoYwI=
2+
github.com/corona10/goimagehash v1.1.0/go.mod h1:VkvE0mLn84L4aF8vCb6mafVajEb6QYMHl2ZJLn0mOGI=
3+
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
4+
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
5+
github.com/flopp/go-findfont v0.1.0 h1:lPn0BymDUtJo+ZkV01VS3661HL6F4qFlkhcJN55u6mU=
6+
github.com/flopp/go-findfont v0.1.0/go.mod h1:wKKxRDjD024Rh7VMwoU90i6ikQRCr+JTHB5n4Ejkqvw=
7+
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
8+
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
9+
github.com/goccy/go-graphviz v0.2.10 h1:jHu/1I0Iw0xIzzYk96Ous/ZeuD11Rt2oW8juHdIE30g=
10+
github.com/goccy/go-graphviz v0.2.10/go.mod h1:LRlMnNmY17QbN6fLnvOzY7g0rXQjLKAhzxeTHbEUM6w=
11+
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
12+
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
13+
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
14+
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
115
github.com/stablekernel/crucible/gen v0.1.0 h1:IdQlLIsoIYMlUgWeZiY3pzTBvxe5wBZhpPaZ02xW/NY=
216
github.com/stablekernel/crucible/gen v0.1.0/go.mod h1:rl6qLJ21rp9B/nMYiAM0sbSgi+2wE0NXP+7+p/rc1RE=
317
github.com/stablekernel/crucible/state v1.0.0 h1:YjhEM3vqqHptq33j+zZlYgSa4FupAFrDgAUUPfLwx68=
418
github.com/stablekernel/crucible/state v1.0.0/go.mod h1:GU2LNVI+FJrdii+UNZYqfwcH219dCKO+TkX6KRE/Fys=
19+
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
20+
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
21+
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
22+
golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s=
23+
golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78=
24+
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
25+
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
26+
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
27+
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
28+
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=

cmd/crucible/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ Usage:
8585
8686
Commands:
8787
lint <ir.json> [-format f] run static analysis; -format text (default), json, or sarif
88-
render <ir.json> [-format f] render the machine as mermaid (default) or dot
88+
render <ir.json> [-format f] [-o file] render as mermaid (default), dot, svg, or png (svg/png embed Graphviz; no external install)
8989
diff <old.json> <new.json> [-format f] [-exit-code] classify changes and recommend a semver bump
9090
validate <ir.json> confirm the IR loads and assembles
9191
eject <ir.json> [-package p] [-o f] generate typed Go behavior stubs

0 commit comments

Comments
 (0)