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.

One sprite URL, four files A sprite base URL expands into four requests: base.png and base.json for standard displays, and base@2x.png and base@2x.json for high-density displays. The 2x JSON contains the same icon names with doubled coordinates and a pixelRatio of 2. A note records that all four must exist because a missing 2x pair does not fall back reliably. "sprite": ".../basemap" no extension in the style basemap.png basemap.json basemap@2x.png basemap@2x.json standard density high density — same names, doubled coords Publishing only the 1x pair is the common omission, and on a retina display it renders soft rather than failing.
Four files, one version. Shipping them separately is what allows a cached sheet to pair with a newer index, which draws the wrong rectangles.

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.
Rendered at 2x versus upscaled to 2x A magnified icon edge shown twice. Rendered from the vector at 2x, the stroke edge falls on a clean pixel boundary with a single antialiasing step. Upscaled from the 1x raster, the same edge spans four pixels of interpolated grey, so the icon reads soft at exactly the density that motivated the second sheet. rendered from the vector at 2× one antialiasing step at the edge upscaled from the 1× raster four pixels of interpolated grey Both sheets have identical dimensions and pass every structural check. Only one of them is sharp, and it is the high-density display — the reason the second sheet exists — that shows the difference.
The upscaled sheet satisfies every automated check: right name, right dimensions, consistent index. It simply fails at the one job the 2x variant exists to do.

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.

Caching by source hash keeps an incremental build incremental Two build runs over a two-hundred-icon set. Without a cache, both runs render four hundred rasters and take 46 seconds each. With a cache keyed on source hash and pixel ratio, the second run after a single icon edit renders two rasters and takes under a second, with the packing step still running in full because the layout must be re-emitted. no cache — every build 400 rasterisations pack 46 s cached by source hash — after one icon edit pack 0.8 s 2 rasterisations — the edited icon at both ratios The packing step always runs, because the layout has to be re-emitted — but it is milliseconds, and with a sorted layout the result is byte-identical unless the icon set actually changed. That combination is what makes an icon edit a one-second feedback loop rather than a minute.
Rasterisation is pure and expensive; packing is cheap and must always run. Splitting the cache along that line gives the whole speed-up with none of the staleness risk.

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.


Back to Symbol and Sprite Pipelines