Building Mapbox Sprite Sheets from an SVG Icon Library
A GL style requests four files from one sprite URL — a PNG and a JSON at 1x, and the same pair at 2x — so the build has to emit all four, render each raster from the vector rather than by upscaling, and pack deterministically so an unchanged icon set produces an unchanged sheet.
Core Algorithm and Workflow
The sprite format is deliberately simple: one raster containing every icon, and one JSON object mapping each icon name to a rectangle within it. The renderer downloads both once and thereafter draws icons by copying rectangles out of the texture.
The build therefore has four stages, and the correctness conditions sit between them rather than inside them. Sources must share a canvas so that a size in the style means the same thing for every icon. Rasters must be rendered per ratio, because the entire purpose of a 2x sheet is more detail than the 1x one has. Packing must be deterministic, because a byte-different sheet from identical inputs invalidates every cached copy for nothing. And the index must be generated from the packing rather than maintained beside it, so the two cannot disagree.
Production-Ready Python Implementation
import io
import json
import math
import pathlib
import cairosvg
from PIL import Image
CANVAS = 24 # every source icon shares this viewBox
GUTTER = 1 # transparent pixels between packed cells
def build_sprite(src_dir: str, out_base: str, ratio: int = 1) -> dict:
"""Render, pack and index an icon set at one pixel ratio.
Emits `<out_base>.png` / `.json` for ratio 1 and `<out_base>@2x.*` for 2.
"""
paths = sorted(pathlib.Path(src_dir).glob("*.svg")) # deterministic layout
if not paths:
raise ValueError(f"no SVG sources in {src_dir}")
cell = CANVAS * ratio
rendered = {}
for p in paths:
# Rendered from the vector at this ratio — never upscaled from 1x.
rendered[p.stem] = cairosvg.svg2png(
url=str(p), output_width=cell, output_height=cell)
names = sorted(rendered)
stride = cell + GUTTER
cols = max(1, math.isqrt(len(names)))
rows = math.ceil(len(names) / cols)
sheet_w = cols * stride + GUTTER
sheet_h = rows * stride + GUTTER
if max(sheet_w, sheet_h) > 4096:
raise ValueError(f"sheet is {sheet_w}×{sheet_h}; some GPUs cap at 4096")
sheet = Image.new("RGBA", (sheet_w, sheet_h), (0, 0, 0, 0))
index = {}
for i, name in enumerate(names):
r, c = divmod(i, cols)
x, y = GUTTER + c * stride, GUTTER + r * stride
sheet.paste(Image.open(io.BytesIO(rendered[name])), (x, y))
index[name] = {"width": cell, "height": cell, "x": x, "y": y,
"pixelRatio": ratio}
suffix = "" if ratio == 1 else f"@{ratio}x"
sheet.save(f"{out_base}{suffix}.png", optimize=True)
pathlib.Path(f"{out_base}{suffix}.json").write_text(
json.dumps(index, indent=2, sort_keys=True))
return {"icons": len(names), "sheet": [sheet_w, sheet_h], "ratio": ratio}
Two guards earn their place. The 4096-pixel check catches a set that will fail to upload as a texture on some GPUs — a failure that manifests as a blank icon layer on specific devices with no error anywhere. Sorting names before packing is what makes an unchanged icon set produce a byte-identical sheet, so its content hash is stable and no client re-downloads it after an unrelated rebuild.
Performance Tuning and Cartographic Best Practices
- Cache rasterisation by source hash and ratio. Rendering two hundred SVGs twice is the slow part, and it is pure: the same bytes at the same size always give the same PNG. A build that changed one icon should re-render one icon.
- Keep the canvas shared and reject anything else. An icon delivered at 32 units among a 24-unit set was drawn at a different optical weight. Rescaling it silently produces a marker whose stroke does not match its neighbours; raising names the file.
- Emit both ratios in one run. Producing 1x today and 2x next week is how the two drift out of sync. Build them from the same source list in the same invocation.
- Version the pair. Publish under a content-hashed path and reference that from the style, for the same reason tile URLs are versioned in Tile Cache and Invalidation: a cached sheet paired with a fresher index draws sliced icons.
- Optimise the PNG, but do not quantise it. Palette reduction on an icon sheet produces banding in antialiased edges that shows up on every marker. Lossless optimisation is free; lossy is not worth the bytes here.
Integration and Next Steps
The index this build emits is the artefact the style validation reads. Cross-check every icon-image reference against it as described in Symbol and Sprite Pipelines, and fail the build on any name that is absent — this is the only point at which a missing icon is detectable, because renderers omit unknown symbols silently. Where the same icon set serves several styles, generate the union and treat unused entries as a note rather than an error.
Frequently Asked Questions
What exactly does a GL style expect from a sprite URL?
A base URL with no extension. Given "sprite": "https://example.com/sprites/basemap" the renderer will request basemap.png, basemap.json, basemap@2x.png and basemap@2x.json. All four should exist. A missing high-density pair does not fall back consistently across renderers, and where a fallback does happen it produces a soft icon rather than an error, so the omission reaches production looking like a rendering quality problem rather than a missing file.
Why does the sheet need a gutter between icons?
Because the sheet is uploaded as a texture and sampled bilinearly. At the edge of an icon the sampler reads a fraction of the adjacent pixel, and without a gap that pixel belongs to another icon — producing a faint coloured fringe. The artefact only appears once a marker is scaled or rotated, so it survives any inspection of the sheet itself and shows up on the map. One transparent pixel between cells removes it, at a cost of a few per cent of sheet area.
Should the index include the anchor point?
The GL sprite index format has no field for it, so the anchor has to travel separately — either as icon-anchor in the style layer or in your own manifest that the style generator consumes. Record it per icon at build time rather than relying on a convention: a pin whose point marks the location and a circle whose centre marks it cannot share a default, and a mismatch puts every marker of one kind a few pixels off its feature.
How do I keep the sheet from invalidating on every build?
Sort the icon names before packing. A layout derived from directory iteration order changes whenever the filesystem returns entries differently, which produces a byte-different sheet from identical sources and invalidates every cached copy for no reason. With a sorted layout the sheet hashes identically across rebuilds, so a deployment that changed only the style leaves clients with the sprite they already hold.
Related
- Sizing Point Symbols Across Zoom Levels Deterministically — what the style does with these icons once they exist.
- Debugging Missing Icons in MapLibre Style Sprites — tracing a blank icon layer back to a build or index fault.
Back to Symbol and Sprite Pipelines