README
¶
ntcharts-svg — Terminal SVG viewer & vector canvas for Bubble Tea
ntcharts-svg 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 ntcharts/v2/picture for image rendering — half-block glyphs anywhere, full-resolution Kitty graphics on terminals that support them (Kitty, Ghostty, WezTerm).
It is the sibling of ntcharts-pdf and mirrors its architecture. The key difference: 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 widget does double duty:
- View existing SVGs — load any
.svgfile or in-memory bytes, rasterize, zoom, and pan. - Generate vector graphics — draw with an immediate-mode
Canvasand chart helpers, export clean SVG (or PNG), and display the result in the same viewer.
Quickstart
package main
import (
"fmt"
"os"
tea "charm.land/bubbletea/v2"
"github.com/NimbleMarkets/ntcharts-svg/svg"
)
type model struct{ sv svg.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.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 := svg.NewWithConfig(svg.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).
Demo
A fuller demo lives at examples/svgview — adds a status bar, help bubble, and the c / e keys that generate and export a chart at runtime.
task build-ex-svgview
./bin/ntcharts-svgview path/to/your.svg # or no argument for the embedded sample
The same example compiles to WebAssembly and runs in the browser via booba — no JS bridge required, since the rasterizer is pure Go. Build and serve it locally:
task serve-wasm-site # builds web/app.wasm, stages booba assets, serves at :8000
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.
Modes
| Mode | What it does | Where it works |
|---|---|---|
svg.RasterMode (default) |
Rasterizes the SVG via a Renderer (default: oksvg/rasterx), fed to ntcharts/v2/picture. Supports zoom (+/- up to ×64) and pan (arrow keys when zoomed) |
Everywhere — native, browser-WASM, WASI |
svg.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 picture.PictureGlyph (universal half-blocks) or picture.PictureKitty (high-resolution); switch between them with sv.ToggleRenderMode().
Unlike ntcharts-pdf, RasterMode is the default — the rasterizer is pure Go and always available, so there is no platform where image rendering silently degrades.
Renderer
type Renderer interface {
Render(maxW, maxH int) (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 Canvas — skip the path round-trip via sv.SetSVGData(name, data) or Config.InitialData / InitialName.
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 crops into that bitmap.
Loading APIs
| 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.ShowCanvas(c) |
Display a *svg.Canvas you drew with the immediate-mode API |
sv.SetImage(img) |
Install a pre-rasterized image.Image, bypassing the Renderer entirely |
Renderer state introspection
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.
Immediate-mode Canvas
Canvas is a self-contained, pure-Go SVG writer — draw with chainable primitives, then export clean SVG, a PNG, or an image.Image.
c := svg.NewCanvas(200, 120).Background("white")
c.Circle(60, 60, 40, svg.Fill("#4e79a7"))
c.Line(0, 0, 200, 120, svg.Stroke("#e15759", 3))
c.Text(100, 110, "hello", svg.Paint{}.WithFont("sans-serif", 12).WithAnchor("middle"))
svgText := c.ToSVG() // standalone SVG markup
_ = c.WriteSVG("out.svg") // write a .svg file
_ = c.WritePNG("out.png", 800, 600) // rasterize to PNG
img, _ := c.ToImage(800, 600) // rasterize to image.Image
Primitives: Line, Rect, RoundRect, Circle, Ellipse, Polyline, Polygon, Path, Text, and Raw for an escape hatch. Paint carries fill / stroke / opacity / dash / font, with fluent With* builders and the Fill / Stroke constructors.
Chart helpers
Chart builds bar and line charts on top of the Canvas:
canvas := svg.NewChart(360, 240).
SetTitle("Quarterly Revenue").
SetLabels("Q1", "Q2", "Q3", "Q4").
AddSeries("2025", "", 12, 19, 14, 23).
AddSeries("2026", "", 17, 15, 21, 28).
BarCanvas() // or LineCanvas()
cmd := mySvgModel.ShowCanvas(canvas) // render it straight into the viewer
This is the "generate chart → SVG → display" loop the c key in the example demonstrates.
Resource limits (Config.Limits)
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.
Sanitizing SVG-derived strings
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 svg.SanitizeForTerminal(s string) string, which drops anything outside Unicode's printable categories and folds newlines / tabs to single spaces.
Known caveats
- 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
Canvasis 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 keepsCanvasdependency-free and WASM-safe. WriteSVG/WritePNGdo filesystem I/O and will fail at runtime underGOOS=js. UseToSVG/Bytes/ToImagein the browser.- Kitty placement composition through lipgloss is best-effort.
picture.Model.View()embeds the Kitty placement escapes directly in the rendered string, which composes cleanly insidelipgloss.JoinVerticaland basic borders but may misbehave under styles that rewrite the content. Glyph mode has no such caveat. - Vibe coded. This comment was inserted by a human.
License
Thanks to the oksvg and rasterx libraries and their authors. We are currently using [a fork]](https://github.com/NimbleMarkets/oksvg/tree/nm) of their oksvg library.
MIT License — Copyright (c) 2026 Neomantra Corp.
Made with ❤ and 🔥 by the team behind Nimble.Markets.
Directories
¶
| Path | Synopsis |
|---|---|
|
examples
|
|
|
svgview
command
examples/svgview/main.go — single-pane svgview demo.
|
examples/svgview/main.go — single-pane svgview demo. |
|
Package svg is a Bubble Tea v2 widget that makes SVG documents first-class citizens in terminal UIs (and WASM/browser builds).
|
Package svg is a Bubble Tea v2 widget that makes SVG documents first-class citizens in terminal UIs (and WASM/browser builds). |