Rendering SVG to PNG in Pure Go, and Where oksvg Gives Up
Generating OG images server-side with oksvg and rasterx, no CGo and no headless browser. The pure-Go pipeline, and the two places oksvg breaks.
Every post on this blog needs a social share image: a 1200x630 PNG that shows up when someone pastes the link into a chat or a timeline. The cover art is an inline SVG, a small mascot with a topical prop, and it has to become a PNG on the server before it lands in object storage. The obvious tools for that, a headless browser or ImageMagick, both drag a large native dependency into the container. This post is about doing it in pure Go with oksvg and rasterx, and about the two specific places where oksvg stops being enough.
The short version: pure Go gets you a deterministic, dependency-free rasterizer that is perfect for simple shape art, as long as you feed it hex colors and do your own cropping. The moment you need filters, full gradients, or real text layout, the answer goes back to a browser.
Why render SVG on the server at all
A social preview image cannot be computed in the browser, because the crawler that reads it, whether Slack, Discord, or a search engine, never runs your JavaScript. It fetches the HTML, reads the og:image meta tag, and downloads whatever URL is there. So the PNG has to exist as a real file before the first request, which means the server has to produce it.
The design constraint here was that the server runs on Cloud Run, one small container, no GPU, no display. I did not want to bake a browser or a native imaging library into that image. A headless Chrome layer alone is hundreds of megabytes and needs its own set of system libraries. ImageMagick pulls in delegate libraries for every format it touches. Both work, but both make the container bigger, the cold start slower, and the build more fragile.
The cover art itself is simple: flat shapes, a couple of fills, no photographic content. That is the case where a full browser is overkill. If the art is a handful of paths and circles, a pure-Go rasterizer can turn it into a PNG in a millisecond with zero external processes.
One deliberate choice shaped everything else: no text goes into the PNG. Rendering text means shipping fonts, and shipping fonts that cover Korean, Japanese, and both Chinese scripts means a large font payload and a real text-shaping engine. Instead the image carries only shapes, and the title is left to the HTML around it. That single decision removed the hardest part of server-side rendering before it started.
The pure-Go stack: oksvg plus rasterx
Two packages do the work. github.com/srwiley/oksvg parses an SVG document into a drawable icon. github.com/srwiley/rasterx is the rasterizer that turns vector paths into pixels using a scan-line fill. Both are pure Go. There is no CGo, no linked C library, nothing to install in the container beyond the compiled binary.
That property is the whole reason to use them. A pure-Go build means the Dockerfile can be a static binary on a minimal base, the build never breaks because a system library moved, and cross-compilation just works. It also means the output is deterministic. The same SVG string produces the same PNG bytes every time, on every machine, with no font-hinting or GPU-driver variance. Deterministic output makes caching trivial and makes the image content-addressable: hash the bytes, store them under that hash, and you never regenerate or duplicate.
The flow has four stages, and each is a plain function:
- Build the SVG as a string, with the mascot and the prop.
- Parse it with oksvg into an icon.
- Rasterize the icon into an
image.RGBAwith rasterx. - Encode that image to PNG with the standard library.
Stages 1 and 4 are ordinary Go. Stages 2 and 3 are where oksvg and rasterx live, and where the two limitations below show up.
oksvg does not understand hsl()
The first wall is color. I write cover art with HSL colors, because HSL makes it easy to derive a palette: pick a hue, then vary lightness for shadows and highlights while keeping saturation fixed. So the SVG string had fills like hsl(210, 50%, 60%).
oksvg does not parse hsl(). It handles named colors and hex, but an hsl(...) fill silently fails to apply, and the shape renders with no fill or a default. There is no error, which makes it worse: the parse succeeds, the draw runs, and the shape is just wrong.
The fix is to convert HSL to hex before the color ever reaches the SVG string. HSL to RGB is a short, well-defined function, so I compute it in Go and emit #rrggbb:
package cover
import (
"fmt"
"math"
)
// hslToHex converts an HSL color to the #rrggbb string oksvg accepts.
// h is in [0, 360), s and l are in [0, 1].
func hslToHex(h, s, l float64) string {
c := (1 - math.Abs(2*l-1)) * s
hp := h / 60
x := c * (1 - math.Abs(math.Mod(hp, 2)-1))
var r, g, b float64
switch {
case hp < 1:
r, g, b = c, x, 0
case hp < 2:
r, g, b = x, c, 0
case hp < 3:
r, g, b = 0, c, x
case hp < 4:
r, g, b = 0, x, c
case hp < 5:
r, g, b = x, 0, c
default:
r, g, b = c, 0, x
}
m := l - c/2
ri := uint8(math.Round((r + m) * 255))
gi := uint8(math.Round((g + m) * 255))
bi := uint8(math.Round((b + m) * 255))
return fmt.Sprintf("#%02x%02x%02x", ri, gi, bi)
}
With that helper, the palette stays HSL in the Go code, where it is easy to reason about, and the SVG only ever sees hex. Note that the function rounds each channel, so the output is stable and the whole pipeline stays deterministic. If you let two callers round differently, you break byte-for-byte reproducibility, so keep one conversion path.
The general lesson: oksvg implements a subset of the SVG color spec, and the safe assumption is that anything beyond named colors and hex needs to be resolved to hex in your own code first. That includes hsl(), hsla(), and modern color functions. Resolve them up front and the rasterizer never sees a color it cannot handle.
preserveAspectRatio slice is unreliable, so crop yourself
The second wall is aspect ratio. The target is 1200x630, an aspect ratio of about 1.905. The cover art is authored in a square-ish viewBox, because that is a natural canvas for a mascot. So the source and the output do not share a shape, and something has to give.
In a browser, you would set preserveAspectRatio="xMidYMid slice" and the renderer would scale the art to cover the box and clip the overflow, like CSS background-size: cover. oksvg has partial support for preserveAspectRatio, but the slice mode is unreliable when the source and target ratios differ by a lot. In practice the art either stretched, so circles became ovals, or clipped in the wrong place. The behavior was inconsistent enough that I stopped trusting it.
The reliable path is to not ask oksvg to fit anything. Render at the viewBox aspect ratio, scaled up so it fully covers the target, then center-crop the result down to the exact target size with the standard library. The crop is a plain pixel copy, which oksvg is not involved in, so it cannot get it wrong.
First, compute a render size that covers the target box, the same math CSS cover uses:
// coverSize scales the source viewBox so it fully covers the target box,
// matching CSS background-size: cover. The result is >= target on both axes.
func coverSize(vbW, vbH, targetW, targetH float64) (int, int) {
scale := math.Max(targetW/vbW, targetH/vbH)
return int(math.Ceil(vbW * scale)), int(math.Ceil(vbH * scale))
}
Rasterize at that size:
import (
"fmt"
"image"
"strings"
"github.com/srwiley/oksvg"
"github.com/srwiley/rasterx"
)
func rasterize(svg string, w, h int) (*image.RGBA, error) {
icon, err := oksvg.ReadIconStream(strings.NewReader(svg), oksvg.WarnErrorMode)
if err != nil {
return nil, fmt.Errorf("parse svg: %w", err)
}
icon.SetTarget(0, 0, float64(w), float64(h))
img := image.NewRGBA(image.Rect(0, 0, w, h))
scanner := rasterx.NewScannerGV(w, h, img, img.Bounds())
raster := rasterx.NewDasher(w, h, scanner)
icon.Draw(raster, 1.0)
return img, nil
}
Then center-crop to the exact target:
import (
"image"
"image/draw"
)
// centerCrop copies the middle target-sized rectangle out of a larger image.
func centerCrop(src *image.RGBA, targetW, targetH int) *image.RGBA {
b := src.Bounds()
x0 := b.Min.X + (b.Dx()-targetW)/2
y0 := b.Min.Y + (b.Dy()-targetH)/2
dst := image.NewRGBA(image.Rect(0, 0, targetW, targetH))
draw.Draw(dst, dst.Bounds(), src, image.Pt(x0, y0), draw.Src)
return dst
}
Because coverSize guarantees the rendered image is at least as large as the target on both axes, the crop offsets are never negative and the copy always has enough source pixels. The art scales uniformly, so nothing stretches, and the crop takes the center, which is where the mascot sits. This does the job that slice was supposed to do, but in code you control.
The full pipeline, end to end
Putting the pieces together, one function takes the SVG string and target size and returns PNG bytes:
import (
"bytes"
"image/png"
)
// RenderPNG rasterizes an SVG at the viewBox aspect, crops to the target box,
// and encodes to PNG. The output is deterministic for a given input.
func RenderPNG(svg string, vbW, vbH, targetW, targetH int) ([]byte, error) {
rw, rh := coverSize(float64(vbW), float64(vbH), float64(targetW), float64(targetH))
full, err := rasterize(svg, rw, rh)
if err != nil {
return nil, err
}
cropped := centerCrop(full, targetW, targetH)
var buf bytes.Buffer
if err := png.Encode(&buf, cropped); err != nil {
return nil, fmt.Errorf("encode png: %w", err)
}
return buf.Bytes(), nil
}
There are no external processes, no temp files, and no network calls. The function is pure computation, so it is easy to test: feed a fixed SVG, hash the bytes, and assert the hash. Because the whole path is deterministic, that test stays green across machines and Go versions, and the same property lets the caller use the content hash as the storage key. Generate once, store under og/{hash}.png, and every later request with the same cover art resolves to the same object with no regeneration.
The cost profile is what you want for this job. A cover of a few dozen paths encodes in around a millisecond, allocates a couple of RGBA buffers, and returns. There is no browser to spawn, no process to reap, and no shared native state to serialize access to. It runs happily inside the request path or an ingest job with no special handling.
What oksvg renders, and what it drops
The honest boundary is that oksvg implements part of the SVG spec, not all of it. For flat shape art it is more than enough. For anything richer it will quietly drop features. Here is the rough shape of what held up and what did not for this use case.
| SVG feature | oksvg support | Notes |
|---|---|---|
| Paths, rects, circles, polygons | Full | The core of the cover art, renders cleanly |
| Hex and named fills and strokes | Full | Use these, not hsl() |
| hsl() and modern color functions | None | Convert to hex in Go first |
| Linear gradients | Partial | Simple cases work, complex stops can be off |
| preserveAspectRatio slice | Unreliable | Render at native ratio and crop yourself |
| Filters (blur, drop shadow) | None | Not implemented, silently ignored |
| Text with real font layout | Avoid | Needs fonts and shaping, kept out by design |
The pattern across the failures is the same: when oksvg does not support something, it usually does not error. It parses, it draws, and the missing feature is just absent from the output. That makes the failures easy to miss in a quick visual check and important to catch with a golden-image test. Render your art, look at every pixel once, and lock the output with a byte hash so a later edit that trips an unsupported feature shows up as a diff.
For the cover art here, the constraints were easy to live with, because the art was designed to fit them. Flat fills, hex colors, no filters, no embedded text. If your source art was drawn without those limits in mind, expect to simplify it before oksvg will render it faithfully.
When you actually need a browser
Pure Go is the right tool when the SVG is simple and you control it. It stops being the right tool at a clear line, and it is worth naming that line so you do not fight the rasterizer past its limits.
Reach for a headless browser when the art uses SVG filters, when it depends on gradients or masks that oksvg renders incorrectly, or when it contains real text that has to be laid out with fonts, especially across multiple scripts. A browser is a complete SVG and CSS engine with a full text-shaping stack, and it will render a complex document correctly where oksvg would drop half of it. The price is the one this whole approach was avoiding: a large container, a slower cold start, and a heavier build. That price is worth paying when the output demands it, and wasteful when it does not.
The rule that came out of this: match the renderer to the art. If the cover is flat shapes and hex colors with no text, a pure-Go rasterizer gives you a small, deterministic, dependency-free path that runs anywhere Go runs. If the art needs the real SVG spec, use the engine that implements the real SVG spec, and accept the container that comes with it. The mistake is trying to push complex art through oksvg, or dragging a browser into a job that a millisecond of pure Go could have finished.
Related posts
Hot-Swapping Cron Schedules with MongoDB Change Streams
Hardcoded cron means a redeploy every time a schedule changes. Store schedules in the database and have a daemon react to edits with change streams. Here is the design and its failure handling.
Load Balancing a GPU Worker Pool with Redis Keyspace Notifications and Leases
GPUs are expensive and run one heavy job at a time, so round-robin routing stalls behind busy workers. Here is a busy-aware scheduler built from Redis leases and keyspace notifications.
Lease Locks with CAS and Heartbeat for Single-Runner Jobs
A plain lock deadlocks forever when the holder dies. A lease expires. Here is how to build one with compare-and-swap acquisition and a heartbeat, and the failure modes that decide the design.
Deterministic ULIDs for Idempotent Upserts
When a database forbids automatic write retries, every write must be idempotent on its own. A content-derived id makes that free. Here is the derivation and the one invariant that keeps it safe.
The retryWrites=false Trap in Firestore's MongoDB Compatibility
Firestore speaks the MongoDB wire protocol, but rejects retryable writes. If your driver defaults to on, writes fail in a way that looks random. Here is why, and the fix.
Moving a Backend from Rust to Go, and Why Build Time Decided It
Rust gave us safety we rarely needed and a compile loop we fought every day. Here is the honest tradeoff, with numbers, that pushed a service to Go.