@tecsteps/kurviq-toolbelt
v0.6.0
Published
Render, compare and evaluate SVG deterministically. The agent-facing companion to kurviq.
Maintainers
Readme
@tecsteps/kurviq-toolbelt
Deterministic rendering, comparison and evaluation of SVG, the agent-facing
companion to kurviq.
kurviq itself stays zero-dependency and unchanged. This package is separate
because rendering needs a real rasteriser, and an agent needs one that is
pinned and reproducible.
Why
A converter that cannot answer questions about its own output forces every caller to build a QA harness. Building Kurviq v1.2 did exactly that, and the harness found five defects that reading the SVG had missed, including one pass that rendered every image black and another that rendered blank.
Install
npm i @tecsteps/kurviq-toolbeltUse
const { evaluate } = require('@tecsteps/kurviq-toolbelt');
// Authoring: no reference needed.
const { report, artifacts } = evaluate({ svg, width: 512 });
// Verification: compare against the source raster.
const check = evaluate({ svg, referencePng });
check.report.comparison.exactRgba; // render-identical?
check.report.comparison.changedBounds; // where it changed
check.report.comparison.perceptual; // versioned SSIM/DSSIMkurviq-toolbelt evaluate --svg out.svg --reference in.png \
--report evaluation.json --render rendered.pngBatch: a directory of traces against a directory of sources
kurviq-toolbelt evaluate \
--candidate-dir out/ --reference-dir src/ \
--report-dir reports/ --fail-under-ssim 0.94PASS 0.9922 flat-a
PASS 0.9912 flat-b
FAIL 0.1438 noisy (ssim 0.1438 < 0.94)
2/3 passed; kept 1117 B of SVG against 4023 B of referenceCandidates pair with references by basename, so out/logo.svg matches
src/logo.png. --manifest pairs.json takes explicit pairs instead:
[{ "name": "logo", "candidate": "out/logo.svg", "reference": "src/logo.png" }]--report-dir writes one report per asset plus a summary.json carrying every
row and the totals, including how many bytes of SVG the passing assets would
replace. Without it the summary goes to stdout.
A candidate with no reference is an error for that asset, not a silent skip, and the run still evaluates everything else. Errors exit 2; gate breaches exit 3, so "the tool could not run" stays distinguishable from "the traces are not good enough".
Use it as a quality gate
evaluate used to exit 0 no matter how bad the result was, so every CI or agent
use needed a wrapper that parsed the JSON and compared a number. It does not:
kurviq-toolbelt evaluate --svg out.svg --reference in.png --fail-under-ssim 0.94
# ssim 0.9770 dssim 0.0115 1400x800 19342 B svg changed 98.2%
echo $? # 0 pass, 3 gate breached| Flag | Effect |
|---|---|
| --fail-under-ssim <n> | Exit 3 when perceptual.ssim is below n |
| --fail-on-dimension-mismatch | Exit 3 when the sizes differ |
| --quiet | Suppress the summary line |
Exit codes are 0 pass, 1 usage error, 2 input refused, 3 quality gate breached. They are distinct so a pipeline can tell "the tool could not run" from "the trace is not good enough".
The one-line summary goes to stderr, so a report piped from stdout stays parseable:
kurviq-toolbelt evaluate --svg out.svg --reference in.png | jq .comparison.perceptual.ssimCraft instruments
The rest of this package answers correctness questions, and that vocabulary comes from tracing, where a reference raster always exists. Authoring from imagination has no reference, so it goes inert. These answer "does this look right, and by how much is it wrong".
const { inkCentroid, contribution, zoom, contrast } = require('@tecsteps/kurviq-toolbelt');
// Which way does the highlight actually point? Compare it to your light.
inkCentroid(svg, { x: 40, y: 40, width: 120, height: 120 }, { width: 800 }).angle;
// Do the light shafts earn their bytes?
contribution(svg, 'shafts', { width: 800 }).negligible; // true => they do not
// Look closely, without editing the viewBox.
zoom(svg, { x: 200, y: 120, width: 80, height: 80 }, { scale: 6 });
// Is the label readable on that busy background?
contrast(svg, labelBox, backgroundBox, { width: 800 }).passesAA;An arm of a controlled experiment that had no library beat the arm that had one, and this is why: it built these instruments and measured its way to a better drawing, finding its planet terminators were 31 to 42 degrees off. The other arm tuned by eye.
Fonts
<text> renders to nothing unless you supply fonts, and system fonts are never
loaded because they would make renders machine-dependent. Pass TTF/OTF:
evaluate({ svg, width: 800, fonts: ['./Inter.ttf'] });Without them a document containing text raises FONT_UNAVAILABLE rather than
painting a blank where the labels should be. woff2 raises
UNSUPPORTED_FONT_FORMAT, because the renderer would otherwise accept it and
render nothing.
Guarantees
- Deterministic. System fonts are never loaded; external resources are refused rather than fetched; the renderer name and version are in every report.
- Honest about size. A size mismatch is reported. Nothing is cropped or padded to manufacture a comparison.
- Honest about alpha. Hashes cover RGBA, so a transparent pixel never collides with a white one. Alpha is compared separately from colour.
- Honest about scale. SSIM is
null, never1.0, when the image is smaller than the 8×8 window. - Stable schema.
comparisoncarries the same keys whether or not the dimensions matched, withnullwhere nothing was measured. Readingcomparison.perceptual.ssimnever throws. A machine consumer needs a predictable shape more than a compact one. - Honest about mean absolute error. Removing a background changes alpha
across the whole canvas, which moves MAE by roughly 90x against an opaque
reference while the two images stay visually near-identical. When that
happens the report sets
meanAbsoluteErrorNotetelling you to useperceptual.ssiminstead, rather than letting the obvious scalar read as a broken trace. - Honest about time.
mode: "resvg-static"renders one frame with the animation clock unstarted. It proves the static/fallback frame is correct and says nothing about whether the document animates.
Verbs
| Verb | Purpose |
| --- | --- |
| evaluate(input) | render, optionally compare, optionally verify states; returns a report plus the rendered PNG |
| renderStatic(svg, opts) | one deterministic frame: no system fonts, external references refused |
| compareRgba(a, b, opts) | exact and perceptual comparison; size mismatch reported, never patched |
| compileStateDocument(svg, state) | make :hover / :target / reduced-motion apply in a static renderer |
| validateTarget(svg, profile) | check a document against a measured support matrix |
| detectFeatures(svg) | which matrix features the document actually uses |
State verification
resvg has no interaction model, so a state is compiled into a static document, the pseudo-class is stripped, other states are dropped, and then rendered and diffed:
const { report } = evaluate({ svg, width: 400, states: ['base', 'hover'] });
report.states.hover.visiblyDiffers; // false means the rule paints nothing
report.states.hover.changedBounds; // where it changedThis proves the state's declarations produce the intended pixels. It does not prove a browser delivers the event, that the hit area is right, or that focus order is sane.
Measured target facts
Established by rendering here, not read from documentation. @resvg/resvg-js
2.6.2: CSS custom properties render black (fallback ignored); CSS and SMIL
animation never execute; pathLength is ignored at every value;
mask-type: alpha falls back to luminance; there is no interaction model.
Browser columns are marked assumed because we have no browser oracle yet ,
claiming otherwise would be the dishonesty the matrix exists to prevent.
Not in this release
Time/animation execution (an AnimationPlan can be sampled, but only a
browser can prove it runs), heatmaps, policy budgets, candidate search, and full
provenance receipts. See specs/kurviq/CONCEPT-00-AGENT-TOOLBELT.md.
License
MIT
