ntsvg (nimbleterminal.dev/ntsvg) is a Bubble Tea widget that makes SVG documents first-class citizens in terminal UIs (and WASM/browser builds). It pairs oksvg + rasterx for pure-Go SVG rasterization with ntpicture (nimbleterminal.dev/ntpicture) for image rendering — half-block glyphs anywhere, full-resolution Kitty graphics on terminals that support them (Kitty, Ghostty, WezTerm).
Because the SVG rasterizer is pure Go, image rendering needs no CGO, no system dependencies, and no JS bridge — it works identically on native, browser-WASM, and WASI builds.
The module provides two packages:
nimbleterminal.dev/ntsvg— the terminal SVG viewer widget: load any.svgfile, in-memory bytes, or generated*stage.Stage, rasterize, zoom, and pan.nimbleterminal.dev/ntsvg/stage— a pure-Go, dependency-free immediate-mode vector stage and chart builder that emits standalone SVG markup (ToSVG,Bytes,WriteTo,WriteSVG).
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"nimbleterminal.dev/ntsvg"
)
type model struct{ sv ntsvg.Model }
func (m model) Init() tea.Cmd { return m.sv.Init() }
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if k, ok := msg.(tea.KeyMsg); ok && (k.String() == "q" || k.String() == "ctrl+c") {
return m, tea.Sequence(m.sv.Dispose(), tea.Quit)
}
if sz, ok := msg.(tea.WindowSizeMsg); ok {
return m, m.sv.SetSize(sz.Width, sz.Height)
}
var cmd tea.Cmd
m.sv, cmd = m.sv.Update(msg)
return m, cmd
}
func (m model) View() tea.View { return m.sv.View() }
func main() {
sv := ntsvg.NewWithConfig(ntsvg.Config{InitialPath: os.Args[1]})
if _, err := tea.NewProgram(model{sv: sv}).Run(); err != nil {
fmt.Println(err); os.Exit(1)
}
}Toggle Raster ↔ Info with m, swap Glyph ↔ Kitty with g, zoom with + / -, pan with arrows, cycle fit with f, reload with r, reset view with 0. The widget's Update dispatches all of these through DefaultKeyMap(); rebind a field to change the keystroke (or set a binding to key.Binding{} to disable it).
Building requires Go 1.26.8 or newer. See examples/README.md for full details:
task run-gallery(examples/gallery) — browse five embedded SVG samples (examples/gallery/samples/) across Raster and Info modes, fit policies, and zoom levels.task run-stage(examples/stage) — interactive immediate-mode vector stage (stage.Stage) with geometric primitives, cubic Bezier waves, procedural rosettes, and telemetry HUD cards.task run-charts(examples/charts) — live grouped bar and multi-series line chart builder usingstage.Chart.task run-mermaid(examples/mermaid) — generate Mermaid diagrams as SVG with upstream go-mermaid, cycle diagram types and themes, and zoom/pan through ntsvg.task run-svgview(examples/svgview) — single-pane CLI viewer (./bin/ntsvg-svgview [path.svg]) with a status bar, help bubble, andc/ekeys to generate and export a chart at runtime.
All demos automatically select Kitty graphics when terminal support is detected, and otherwise use half-block glyph rendering (g toggles manually).
The same examples compile to WebAssembly and run in the browser sample gallery via booba, publishing their key bindings as interactive action buttons above the terminal:
task serve-wasm-site # builds web/demos/*.wasm, stages booba assets, serves http://localhost:8000/web/task build-wasm-site alone produces the deployable web/ directory; the .github/workflows/pages.yml workflow publishes it to GitHub Pages on every push to main.
The browser demo uses booba v0.9.2 and requests ntpicture's Kitty shared-memory
transport, passing raw RGBA frames without PNG encoding or base64 image payloads.
It falls back to direct PNG when the browser bridge is unavailable. Native builds
keep direct PNG transport. Library callers can opt in through
Config.PictureConfig.KittyMedium and select a direct format through
Config.PictureConfig.KittyFormat; call Model.Dispose() (returning the terminal
placement cleanup tea.Cmd) or Model.Close() when disposing the widget to
release pending shared-memory frames.
| Mode | What it does | Where it works |
|---|---|---|
ntsvg.RasterMode (default) |
Rasterizes the SVG via a Renderer (default: oksvg/rasterx), fed to nimbleterminal.dev/ntpicture. Supports zoom (+/- up to ×64) and pan (arrow keys when zoomed) |
Everywhere — native, browser-WASM, WASI |
ntsvg.InfoMode |
Zero-dependency textual summary: view box, declared size, element-type histogram, embedded <title> / <desc> |
Everywhere |
sv.ToggleMode() returns a tea.Cmd that swaps modes and re-renders. RasterMode internally uses ntpicture.PictureGlyph (universal half-blocks) or ntpicture.PictureKitty (high-resolution); switch between them with sv.ToggleRenderMode().
type Renderer interface {
Render(maxW, maxH int) (image.Image, error)
RenderRegion(maxW, maxH int, nx, ny, nw, nh float64) (image.Image, error)
ViewBox() (w, h float64)
Close() error
}
type RendererFactory func(name string, data []byte) (Renderer, error)The factory is bytes-first: the widget reads file contents once (subject to Limits.MaxFileBytes) and hands the bytes to the factory, so custom factories never have to implement their own file I/O. Hosts that already have the bytes — an //go:embed blob, an HTTP response, a generated stage.Stage — skip the path round-trip via sv.SetSVGData(name, data), sv.ShowStage(st), or Config.InitialData / InitialName / InitialStage.
When Config.RendererFactory is nil, NewWithConfig installs DefaultRendererFactory(), backed by oksvg/rasterx. Bring-your-own renderers (resvg via CGO, a server-side rasterizer, a caching layer) plug in through the same interface. Config.RenderEdge (default 1024) sets the longer-edge resolution of the rasterized bitmap; zoom then re-rasterizes the sub-region via RenderRegion.
| Call | When to use |
|---|---|
sv.SetSVG(path) / Config.InitialPath |
SVG is on disk; widget reads it once (Limits.MaxFileBytes enforced via os.Stat) |
sv.SetSVGData(name, data) / Config.InitialData + InitialName |
SVG bytes already in memory (embedded, fetched, generated) |
sv.ShowStage(st) / Config.InitialStage |
Display a *stage.Stage you drew with nimbleterminal.dev/ntsvg/stage |
sv.SetImage(img) |
Install a pre-rasterized image.Image, bypassing the Renderer entirely |
sv.HasRenderer() bool— true when the factory produced a Renderer for the current document.sv.RendererErr() error— the most recent factory error, persisted for the document's lifetime (cleared on the next load). InfoMode still works; surface this to explain why RasterMode is unavailable.
stage.Stage is a self-contained, pure-Go SVG writer with zero external dependencies — draw with chainable primitives, then export clean SVG or rasterize via ntsvg:
import (
"nimbleterminal.dev/ntsvg"
"nimbleterminal.dev/ntsvg/stage"
)
st := stage.New(200, 120).Background("white")
st.Circle(60, 60, 40, stage.Fill("#4e79a7"))
st.Line(0, 0, 200, 120, stage.Stroke("#e15759", 3))
st.Text(100, 110, "hello", stage.Paint{}.WithFont("sans-serif", 12).WithAnchor("middle"))
svgText := st.ToSVG() // standalone SVG markup
_ = st.WriteSVG("out.svg") // write a .svg file
_ = ntsvg.WriteStagePNG("out.png", st, 800, 600) // rasterize to PNG
img, _ := ntsvg.RasterizeStage(st, 800, 600) // rasterize to image.ImagePrimitives: Line, Rect, RoundRect, Circle, Ellipse, Polyline, Polygon, Path, Text, and Raw for an escape hatch. Paint carries fill / stroke / opacity / dash / linecap / font, with fluent With* builders and the Fill / Stroke constructors.
stage.Chart builds bar and line charts on top of stage.Stage:
st := stage.NewChart(360, 240).
SetTitle("Quarterly Revenue").
SetLabels("Q1", "Q2", "Q3", "Q4").
AddSeries("2025", "", 12, 19, 14, 23).
AddSeries("2026", "", 17, 15, 21, 28).
BarStage() // or LineStage()
cmd := mySvgModel.ShowStage(st) // render it straight into the viewerThis is the "generate chart → Stage → SVG → display" loop the c key in the example demonstrates.
SVG is an untrusted-input format; a hostile document can nest elements millions deep, inline a gigabyte of base64 image data, or declare a viewBox that projects to a terabyte bitmap. Config.Limits exposes conservative caps applied automatically:
| Field | Default | What it caps |
|---|---|---|
MaxFileBytes |
64 MiB | SVG size before the loader reads it |
MaxElements |
250 000 | Element-histogram counting; the scan stops and reports "capped" |
MaxRenderPixels |
100 000 000 | Rasterized bitmap area; the renderer scales down to fit |
MaxRenderEdge |
8192 | Either edge of the rasterized bitmap |
Set any field to -1 to disable that cap for trusted input; zero uses the default. SVG parsing and rasterization are both panic-guarded — a malformed document surfaces an error rather than crashing the load goroutine.
SVG titles, descriptions, element names, and error messages can carry C0/C1 control sequences and Unicode bidi format chars ("Trojan Source"-style attacks). The widget's own status text is already sanitized. For host-displayed strings derived from the SVG — Model.Title(), Model.Name() for in-memory docs, RendererErr().Error() — use ntsvg.SanitizeForTerminal(s string) string, which drops anything outside Unicode's printable categories and folds newlines / tabs to single spaces.
- WASM dependencies.
wasm.workselects the Bubble Tea fork with browser support; native builds use the upstream dependency ingo.mod. The WASM tasks and CI select this workspace automatically; manual builds needGOWORK="$PWD/wasm.work" GOOS=js GOARCH=wasm go -C examples build -o ../web/demos/gallery.wasm ./gallery. - Zoom is vector-sharp. With a document
Rendererattached,ZoomIn/PanLeft(etc.) re-rasterize the viewport sub-rectangle viaRenderer.RenderRegion, so zoomed-in views stay crisp at any depth rather than upscaling a fixed bitmap. Zoom 0 reuses the full-document bitmap (no re-render); each zoom/pan keypress at depth costs one rasterize. A host bitmap installed viaSetImagehas no renderer, so it still crops (chunky-pixel inspection) — there are no vectors to re-rasterize. oksvgsupports a broad but not exhaustive slice of SVG. Filters, advanced gradients, embedded fonts, and CSS-heavy documents may render approximately or skip elements (the parser runs in ignore-errors mode). For pixel-perfect fidelity, wire a customRendererFactoryaround a stricter rasterizer.- The immediate-mode
stage.Stageis a writer, not a layout engine. It emits exactly the elements you draw. Text is not measured —text-anchorpositions runs, but you supply the coordinates. This keepsstagedependency-free and WASM-safe. WriteSVG/WritePNG/WriteStagePNGdo filesystem I/O and will fail at runtime underGOOS=js. UseToSVG/Bytes/WriteTo/RasterizeStagein the browser.- Kitty placement composition through lipgloss is best-effort.
ntpicture.Picture.View()embeds the Kitty placement escapes directly in the rendered string, which composes cleanly insidelipgloss.JoinVerticaland basic borders. Glyph mode has no such caveat. - Vibe coded. This comment was inserted by a human.
Thanks to the oksvg and rasterx libraries and their authors. We are currently using a fork of their oksvg library.
MIT License — Copyright (c) 2026 Neomantra Corp.
Made with ❤️ and 🔥 by the team behind Nimble.Markets.