https://github.com/nimblemarkets/ntcharts-svg
SVG rendering and immediate-mode for ntcharts
https://github.com/nimblemarkets/ntcharts-svg
Last synced: about 2 months ago
JSON representation
SVG rendering and immediate-mode for ntcharts
- Host: GitHub
- URL: https://github.com/nimblemarkets/ntcharts-svg
- Owner: NimbleMarkets
- License: mit
- Created: 2026-05-17T11:55:06.000Z (2 months ago)
- Default Branch: main
- Last Pushed: 2026-05-24T18:12:16.000Z (2 months ago)
- Last Synced: 2026-05-24T20:15:38.202Z (2 months ago)
- Language: Go
- Size: 78.1 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- License: LICENSE.txt
- Code of conduct: CODE_OF_CONDUCT.md
Awesome Lists containing this project
README
# ntcharts-svg — Terminal SVG viewer & vector canvas for Bubble Tea
`ntcharts-svg` is a [Bubble Tea](https://github.com/charmbracelet/bubbletea) widget that makes SVG documents first-class citizens in terminal UIs (and WASM/browser builds). It pairs [`oksvg`](https://github.com/srwiley/oksvg) + [`rasterx`](https://github.com/srwiley/rasterx) for **pure-Go** SVG rasterization with [`ntcharts/v2/picture`](https://github.com/NimbleMarkets/ntcharts) for image rendering — half-block glyphs anywhere, full-resolution Kitty graphics on terminals that support them (Kitty, Ghostty, WezTerm).
[**Try out the live WASM demo.**](https://nimblemarkets.github.io/ntcharts-svg)
It is the sibling of [`ntcharts-pdf`](https://github.com/NimbleMarkets/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 `.svg` file or in-memory bytes, rasterize, zoom, and pan.
- **Generate vector graphics** — draw with an immediate-mode `Canvas` and chart helpers, export clean SVG (or PNG), and display the result in the same viewer.
## Quickstart
```go
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`](./examples/svgview/main.go) — adds a status bar, help bubble, and the `c` / `e` keys that generate and export a chart at runtime.
```sh
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`](https://github.com/NimbleMarkets/go-booba) — no JS bridge required, since the rasterizer is pure Go. Build and serve it locally:
```sh
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 `` / `` | 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
```go
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`.
```go
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`:
```go
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 `Renderer` attached, `ZoomIn` / `PanLeft` (etc.) re-rasterize the viewport sub-rectangle via `Renderer.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 via `SetImage` has no renderer, so it still crops (chunky-pixel inspection) — there are no vectors to re-rasterize.
- **`oksvg` supports 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 custom `RendererFactory` around a stricter rasterizer.
- **The immediate-mode `Canvas` is a writer, not a layout engine.** It emits exactly the elements you draw. Text is not measured — `text-anchor` positions runs, but you supply the coordinates. This keeps `Canvas` dependency-free and WASM-safe.
- **`WriteSVG` / `WritePNG` do filesystem I/O** and will fail at runtime under `GOOS=js`. Use `ToSVG` / `Bytes` / `ToImage` in 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 inside `lipgloss.JoinVertical` and 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`](https://github.com/srwiley/oksvg) and [`rasterx`](https://github.com/srwiley/rasterx) libraries and their authors. We are currently using [a fork]](https://github.com/NimbleMarkets/oksvg/tree/nm) of their `oksvg` library.
[MIT License](./LICENSE.txt) — Copyright (c) 2026 [Neomantra Corp](https://www.neomantra.com).
----
Made with :heart: and :fire: by the team behind [Nimble.Markets](https://nimble.markets).