// Package pikchr renders Pikchr diagrams — the third markup Fossil
// dispatches on (text/x-pikchr, alongside fossil-wiki and markdown).
// Pikchr is drh's C diagram language; quarry runs it as a WebAssembly
// module under wazero, so the CGO-free constraint holds (the same
// reason the SQLite driver is a Wasm build).
//
// The module is pikchr.c compiled with -DPIKCHR_SHELL to a standalone
// WASI command; its own CLI reads "-" as stdin and writes SVG to
// stdout, so the whole ABI is a pipe — no memory marshalling across the
// host boundary, nothing to get wrong on a resize. Provenance and the
// rebuild recipe live in third_party/PROVENANCE.md.
package pikchr
import (
"bytes"
"context"
_ "embed"
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
)
//go:embed pikchr.wasm
var wasmModule []byte
// renderTimeout bounds one diagram. Pikchr is not Turing-complete and
// real diagrams compile in single-digit milliseconds, so a generous
// ceiling still contains a pathological input.
const renderTimeout = 5 * time.Second
// maxSVG caps a diagram's output; a wiki page is not a place to serve
// megabytes of generated markup.
const maxSVG = 4 << 20
var (
once sync.Once
runtime wazero.Runtime
compiled wazero.CompiledModule
initErr error
)
// ErrUnavailable reports that the Pikchr module could not be prepared;
// callers fall back to preformatted text rather than failing a page.
var ErrUnavailable = errors.New("pikchr: renderer unavailable")
// prepare compiles the module once. Compilation is the expensive step
// (tens of ms); instantiation per render is cheap and gives every
// diagram a fresh, isolated memory.
func prepare() {
ctx := context.Background()
runtime = wazero.NewRuntime(ctx)
if _, err := wasi_snapshot_preview1.Instantiate(ctx, runtime); err != nil {
initErr = fmt.Errorf("%w: wasi: %v", ErrUnavailable, err)
return
}
c, err := runtime.CompileModule(ctx, wasmModule)
if err != nil {
initErr = fmt.Errorf("%w: compile: %v", ErrUnavailable, err)
return
}
compiled = c
}
// Render compiles Pikchr source to an SVG fragment. The returned markup
// is the module's own output and still passes through the caller's
// sanitizer — this package does not gate.
// SyntaxError is pikchr's own report of a diagram it cannot draw, as
// fossil shows it: the offending source line, carets under the fault
// and the ERROR line.
type SyntaxError struct{ Diagnostic string }
func (e *SyntaxError) Error() string {
msg := e.Diagnostic
if i := strings.LastIndex(msg, "ERROR:"); i >= 0 {
msg = strings.TrimSpace(msg[i+len("ERROR:"):])
}
return "pikchr: " + firstLine(msg)
}
func Render(src []byte) ([]byte, error) {
once.Do(prepare)
if initErr != nil {
return nil, initErr
}
ctx, cancel := context.WithTimeout(context.Background(), renderTimeout)
defer cancel()
var stdout, stderr bytes.Buffer
cfg := wazero.NewModuleConfig().
WithName(""). // anonymous: instances must not collide
WithArgs("pikchr", "--svg-only", "-").
WithStdin(bytes.NewReader(src)).
WithStdout(&stdout).
WithStderr(&stderr).
WithSysNanotime() // the module reads no clock; keep it deterministic otherwise
mod, err := runtime.InstantiateModule(ctx, compiled, cfg)
if mod != nil {
defer mod.Close(ctx)
}
if err != nil {
// A non-zero exit is how pikchr reports a syntax error; its
// diagnostic is on stderr and belongs to the author, not us.
if msg := strings.TrimSpace(stderr.String()); msg != "" {
return nil, fmt.Errorf("pikchr: %s", firstLine(msg))
}
// pikchr itself writes a syntax error to stdout: the source
// line, carets under the fault, and an ERROR line.
if diag := strings.TrimRight(stdout.String(), "\n"); ctx.Err() == nil && strings.Contains(diag, "ERROR:") {
return nil, &SyntaxError{Diagnostic: diag}
}
if ctx.Err() != nil {
return nil, fmt.Errorf("pikchr: render exceeded %s", renderTimeout)
}
return nil, fmt.Errorf("pikchr: %w", err)
}
if stdout.Len() > maxSVG {
return nil, fmt.Errorf("pikchr: diagram exceeds %d bytes", maxSVG)
}
return stdout.Bytes(), nil
}
func firstLine(s string) string {
line, _, _ := strings.Cut(s, "\n")
return line
}