Go library and CLI for rendering Vega and Vega-Lite visualization specs to SVG, PNG and vector PDF. Pure Go, no CGO required.
Aster embeds the full Vega/Vega-Lite runtime inside QuickJS (compiled to WASM), with accurate text measurement via go-text/typesetting and PNG rendering via resvg (also compiled to WASM). Everything runs in-process with no external dependencies.
- Vega-Lite to SVG, PNG, vector PDF, or compiled Vega JSON
- Vega to SVG, PNG, or vector PDF
- Arbitrary SVG to PNG or vector PDF conversion
- PDF output is fully vector with subset-embedded fonts (selectable text) — ideal for LaTeX
\includegraphics - Accurate HarfBuzz text shaping with embedded Liberation Sans and monochrome Noto Emoji
- Configurable scale factor for high-DPI PNG output
- Multiple Vega-Lite versions (5.8, 6.4)
- Custom fonts, themes, data loaders, memory limits, and timeouts
- Works on any platform Go supports — no native libraries needed
go get github.com/mgilbir/aster
Requires Go 1.25+.
package main
import (
"log"
"os"
"github.com/mgilbir/aster"
)
func main() {
spec := []byte(`{
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
"data": {"values": [{"a": "A", "b": 28}, {"a": "B", "b": 55}]},
"mark": "bar",
"encoding": {
"x": {"field": "a", "type": "nominal"},
"y": {"field": "b", "type": "quantitative"}
}
}`)
c, err := aster.New()
if err != nil {
log.Fatal(err)
}
defer c.Close()
// Render to SVG.
svg, err := c.VegaLiteToSVG(spec)
if err != nil {
log.Fatal(err)
}
os.WriteFile("chart.svg", []byte(svg), 0644)
// Render to PNG at 2x scale.
png, err := c.VegaLiteToPNG(spec, aster.WithScale(2.0))
if err != nil {
log.Fatal(err)
}
os.WriteFile("chart.png", png, 0644)
}# Install the CLI
go install github.com/mgilbir/aster/cmd/aster@latest
# Render a spec to SVG
aster svg -i chart.vl.json -o chart.svg
# Render a spec to PNG at 2x scale
aster png -i chart.vl.json -o chart.png -scale 2
# Render a spec to vector PDF (subset-embedded fonts, selectable text)
aster pdf -i chart.vl.json -o chart.pdf
# ...with fonts referenced by name only, for later assembly-time embedding
aster pdf -i chart.vl.json -o chart.pdf -text named
# Pipe from stdin to stdout
cat chart.vl.json | aster svg > chart.svg
# Compile Vega-Lite to Vega JSON
aster compile -i chart.vl.json -o chart.vg.json
# Pick a Vega-Lite version and a render timeout
aster svg -i chart.vl.json -version 5.8 -timeout 60s
# Allow specs that load data over HTTP
aster svg -i chart.vl.json -o chart.svg -allow-http
# ...restricted to specific hosts
aster svg -i chart.vl.json -allow-domain cdn.jsdelivr.netThe CLI auto-detects Vega vs Vega-Lite from the $schema field. If absent, Vega-Lite is assumed.
Shared flags: -i/-o (input/output, stdin/stdout when omitted), -version, -timeout, -allow-http, and -allow-domain (repeatable; implies -allow-http). png also accepts -scale and -recode; these don't apply to pdf since it's vector output. pdf accepts -text embed|named|outlines to pick the PDF text mode (default embed).
All rendering goes through a Converter, created with aster.New(). A converter is not safe for concurrent use — create one per goroutine if needed.
c, err := aster.New(
aster.WithVegaLiteVersion("5.8"),
aster.WithTimeout(60 * time.Second),
aster.WithLoader(aster.NewHTTPLoader(nil)),
)
if err != nil {
log.Fatal(err)
}
defer c.Close()Rendering methods:
| Method | Input | Output |
|---|---|---|
VegaLiteToSVG(spec) |
Vega-Lite JSON | SVG string |
VegaLiteToPNG(spec, ...PNGOption) |
Vega-Lite JSON | PNG bytes |
VegaLiteToPDF(spec, ...PDFOption) |
Vega-Lite JSON | PDF bytes |
VegaLiteToVega(spec) |
Vega-Lite JSON | Vega JSON |
VegaToSVG(spec) |
Vega JSON | SVG string |
VegaToPNG(spec, ...PNGOption) |
Vega JSON | PNG bytes |
VegaToPDF(spec, ...PDFOption) |
Vega JSON | PDF bytes |
SVGToPNG(svg, ...PNGOption) |
SVG string | PNG bytes |
SVGToPDF(svg, ...PDFOption) |
SVG string | PDF bytes |
VegaLiteToPDFUsage(spec, ...PDFOption) |
Vega-Lite JSON | PDF bytes + per-font glyph usage |
VegaToPDFUsage(spec, ...PDFOption) |
Vega JSON | PDF bytes + per-font glyph usage |
SVGToPDFUsage(svg, ...PDFOption) |
SVG string | PDF bytes + per-font glyph usage |
Options passed to aster.New():
| Option | Default | Description |
|---|---|---|
WithVegaLiteVersion(v) |
"6.4" |
Vega-Lite version ("5.8" or "6.4") |
WithLoader(l) |
DenyLoader{} |
Data loading strategy (see Loaders) |
WithTimeout(d) |
30s | Max duration per render |
WithMemoryLimit(bytes) |
0 (unlimited) | QuickJS heap limit |
WithTextMeasurement(bool) |
true |
HarfBuzz text shaping for accurate layout |
WithFont(family, ttf) |
— | Register a custom TTF font (used by both measurement and PNG) |
WithDefaultFontFamily(name) |
"Liberation Sans" |
Family that generic sans-serif resolves to (both pipelines) |
WithDefaultSerifFamily(name) |
"Liberation Serif" |
Family that generic serif resolves to (both pipelines) |
WithDefaultMonospaceFamily(name) |
"Liberation Mono" |
Family that generic monospace resolves to (both pipelines) |
WithSystemFonts() |
disabled | Scan system fonts for measurement only (PNG/resvg has no system fonts) |
WithTheme(json) |
— | Vega theme config applied to all renders |
WithTimezone(tz) |
"UTC" |
Timezone for JS Date operations; only "UTC" is supported, other values make New return an error |
PNG options passed per render:
| Option | Default | Description |
|---|---|---|
WithScale(f) |
1.0 |
Scale factor; 2.0 produces 2x dimensions |
WithRecodePNG() |
disabled | Losslessly re-encode into the cheapest equivalent PNG format (indexed/truecolor); same pixels, typically several-fold smaller |
PDF options passed per render:
| Option | Default | Description |
|---|---|---|
WithPDFText(mode) |
PDFTextEmbed |
How text is represented; see below |
PDF text modes:
PDFTextEmbed(default) — real PDF text with subset TrueType fonts embedded: only the glyphs a chart uses ship, once, and each occurrence costs two bytes. Self-contained, selectable, searchable. Text whose font cannot be embedded (CFF outlines, unrecoverable system-font instances) falls back to glyph outlines automatically.PDFTextNamed— the same text structure with fonts referenced by name only, for pipelines that generate many charts and embed the shared font once when assembling the final document. Glyphs are addressed by the IDs of the exact font file used at generation time, so the assembler must embed that same file; standalone viewers will substitute another font and may draw wrong glyphs.PDFTextOutlines— every glyph occurrence becomes filled path outlines. Largest output and text is not selectable, but no font machinery is involved at all.
Shared font embedding — the payoff of PDFTextNamed when composing many charts into one document: render each chart with ...PDFUsage collecting the reported FontUsage (PostScript name, source font bytes, glyph IDs), union the glyph IDs per font across all charts, build one shared subset per font with SubsetFont, and embed that single subset in the composed document. SubsetFont preserves the source's glyph numbering, so the named output's Identity-encoded glyph references resolve against it without remapping. Each font's glyphs are then stored once, no matter how many charts use them.
Loaders control how Vega fetches external data. The default denies all loading for security. Loaders that hold resources (like FileLoader and FallbackLoader) are automatically closed when Converter.Close() is called.
// Deny all external data (default).
aster.New()
// Allow HTTP/HTTPS requests.
aster.New(aster.WithLoader(aster.NewHTTPLoader(nil)))
// Allow HTTP with a custom client (timeouts, proxies, etc).
aster.New(aster.WithLoader(aster.NewHTTPLoader(customClient)))
// HTTP with domain whitelisting — only these hosts are permitted.
aster.New(aster.WithLoader(&aster.HTTPLoader{
Client: http.DefaultClient,
AllowedDomains: []string{"cdn.jsdelivr.net"},
}))
// HTTP with base URL — relative URIs in specs are resolved against it.
aster.New(aster.WithLoader(&aster.HTTPLoader{
Client: http.DefaultClient,
BaseURL: "https://cdn.jsdelivr.net/npm/vega-datasets@v1.29.0/",
}))
// Serve files from a local directory (uses os.Root for path containment).
aster.New(aster.WithLoader(&aster.FileLoader{BaseDir: "./data"}))
// Static test data — returns a JSON payload for any URI, no server needed.
aster.New(aster.WithLoader(&aster.StaticLoader{
Value: []map[string]any{{"a": "A", "b": 28}, {"a": "B", "b": 55}},
}))
// Composite: try local files first, fall back to HTTP.
aster.New(aster.WithLoader(aster.NewFallbackLoader(
&aster.FileLoader{BaseDir: "./data"},
aster.NewHTTPLoader(nil),
)))Available loaders:
| Loader | Description |
|---|---|
DenyLoader |
Rejects all loading (default) |
HTTPLoader |
HTTP/HTTPS with optional AllowedDomains and BaseURL |
FileLoader |
Local files from a base directory, secured with os.Root |
StaticLoader |
Returns a fixed JSON value for any URI (test stub) |
FallbackLoader |
Tries child loaders in order until one succeeds |
HTTPLoader rejects non-HTTP schemes (ftp:, javascript:, data:, file:), URIs with userinfo (user:pass@host), and domains not in the allowlist. Domain matching is case-insensitive. The same policy is re-checked on every HTTP redirect hop, so an allowed host cannot redirect a request to a disallowed one; a CheckRedirect on your own http.Client still applies on top. Response bodies are capped at 64 MiB by default (MaxResponseBytes raises or disables the cap).
When rendering specs from untrusted sources, set BlockPrivateNetworks: true to additionally reject hosts that resolve to loopback, link-local, or private addresses (including cloud metadata endpoints like 169.254.169.254), and pair it with AllowedDomains — name resolution happens at policy-check time, so the flag alone does not defend against DNS rebinding.
FileLoader rejects absolute paths, path traversal (..), and URIs with schemes. It uses Go's os.Root for OS-level path containment, which also blocks symlink escapes.
FallbackLoader naturally routes by URI shape — FileLoader accepts relative paths while HTTPLoader accepts absolute URLs — so combining them covers specs that reference both local and remote data.
The embedded Liberation Sans covers most Latin text. For other scripts or specific fonts:
ttf, _ := os.ReadFile("MyFont-Regular.ttf")
bold, _ := os.ReadFile("MyFont-Bold.ttf")
c, err := aster.New(
aster.WithFont("My Font", ttf),
aster.WithFont("My Font", bold),
aster.WithDefaultFontFamily("My Font"),
)Custom fonts are used for both text measurement (SVG layout) and PNG rendering.
Startup: Creating a Converter loads the full Vega/Vega-Lite module graph (~53-55 ES modules) and initializes the QuickJS WASM runtime. This takes roughly 100-200ms. The PNG renderer (resvg WASM) is lazy-initialized on first PNG render.
Rendering: Most specs render in under 100ms. Geographic visualizations with TopoJSON projections are significantly slower (2-40s) due to the computational cost of coordinate transforms in the JS runtime.
Memory: Each Converter holds a QuickJS WASM instance. Use WithMemoryLimit() to cap heap usage if running untrusted specs.
Concurrency: A Converter is not safe for concurrent use — the underlying WASM runtime is single-threaded. For parallel rendering, create multiple Converter instances.
Reuse: A single Converter can render many specs sequentially. Amortizing startup across renders is the recommended pattern.
The rendering pipeline is:
- Vega-Lite → Vega — Vega-Lite compiler runs in QuickJS (WASM)
- Vega → SVG — Vega runtime runs in QuickJS with Go callbacks for text measurement and data loading
- SVG → PNG — resvg (Rust, compiled to WASM) rasterizes the SVG with embedded fonts
Both WASM runtimes (QuickJS-NG built as a WASI reactor, resvg compiled to WASI) are driven by andsifr, our performance-focused fork of wazero, and run in pure Go with no CGO. The QuickJS binary is built from the pinned upstream release by quickjs-wasm/ (make vendor-quickjs).
The JS environment provides polyfills for APIs that Vega expects but QuickJS lacks:
structuredClone— recursive deep clone preservingundefined,Date/RegExp/Map/Set/typed arrays/DataView, and reference cyclessetTimeout/clearTimeout— microtask-scheduled, no real delays (d3-timer, vega-scenegraph)setInterval/clearInterval— aliased tosetTimeout, so an interval fires exactly once (a static render has no ongoing time in which to repeat)requestAnimationFrame— microtask-scheduled (vega-view)performance.now— wall clock viaDate.now()(not monotonic; only relative timing is used)Datemethods — redirected to UTC equivalents (QuickJS WASM has no timezone config)
The vendored JS modules and both WASM binaries are committed, so a plain
go build ./... works offline with no extra steps. The vendor-* targets
exist to regenerate those assets:
# Re-vendor JavaScript modules (requires network; rewrites committed files)
make vendor-js
# Re-vendor vega-datasets test data
make vendor-datasets
# Rebuild resvg WASM binary (requires Docker)
make vendor-resvg
# Build
go build ./...
# Run tests (fast, skips slow geo specs)
go test -short ./...
# Run full test suite
go test ./...- Timezone: Only UTC is supported. Specs with local-time temporal axes will produce different output than browser-rendered charts.
- Emoji: Monochrome Noto Emoji is bundled as a fallback, so emoji have correct text metrics and rasterize (in black-and-white) in PNG output. Color emoji are not supported — resvg cannot rasterize color-bitmap (CBDT) fonts — so glyphs differ from color-emoji references.
- Interactive features: Selection and signal interactivity are evaluated at initial state only; there is no event loop.
- Remote images in PNG: Image marks referencing external URLs render in SVG output (the URL is embedded as an
href), but the PNG rasterizer runs in a sandboxed WASM module with no network access, so those images are blank in PNG output. Embeddeddata:URLs render fine.
Aster stands on the shoulders of giants. Special thanks to the vl-convert project, whose architecture, test suite, and font choices were invaluable references throughout this project's development.
Thanks also to the Vega and Vega-Lite teams for building such excellent visualization grammars, and to the authors of the key dependencies that make this possible: QuickJS (via QuickJS-NG), fastschema/qjs (which powered earlier versions and whose WASM build informed ours), wazero (via andsifr), resvg, and go-text/typesetting.
MIT