Symbol and Sprite Pipelines

An icon set is an artefact with a build, not a folder of files somebody drops next to a style. It has sources, a compilation step, multiple output variants, an index that the consumer depends on, and a validation step that catches the one failure mode nobody notices — a style referencing an icon that does not exist, which most renderers handle by drawing nothing at all and saying nothing about it.

This page covers that build: normalising SVG sources so sizing becomes arithmetic, rasterising per pixel ratio without upscaling, packing sheets with an index, sizing symbols across zoom so magnitude reads correctly, and validating the style against the index as a gate rather than a hope.

Prerequisites and Environment Configuration

python==3.11
cairosvg==2.7.1
Pillow==10.3.0
lxml==5.2.1
jsonschema==4.21.1

Three conventions have to be fixed before any icon is drawn, and changing them later invalidates every downstream size in the style:

  • A single canvas size for all sources. Typically 24 × 24 units. When every source shares a canvas, the rendered pixel size of an icon is one multiplication rather than a per-icon lookup, and an icon can be swapped without touching the style.
  • A documented anchor point. Centre for most markers, bottom-centre for pins that point at a location. The anchor determines what the icon’s coordinates mean, and an undocumented one produces markers that sit consistently a few pixels off the feature they describe.
  • A single colour hook. Either currentColor throughout, or a designated fill that the recolouring step targets. Icons with several baked colours cannot be themed, and a themed map then needs a separate sprite sheet per theme.

These are the same kind of contract decisions that Rule-Based Styling Engines makes about style properties: cheap to fix at the start, expensive to retrofit across two hundred icons.

Conceptual Foundation: What a Sprite Sheet Actually Is

A sprite is two artefacts that must agree: a raster image containing every icon packed into one bitmap, and a JSON index giving each icon’s name, position, dimensions and pixel ratio. The renderer downloads both once and then draws icons by blitting rectangles out of the sheet, which is why sprites exist at all — one request and one texture upload instead of two hundred.

Everything difficult about sprites follows from that agreement having to hold. The index is generated from the packing, so it cannot drift from the sheet. But the style references icon names, and the style is authored separately, so it can and does drift from both. A style that asks for parking-15 after the icon was renamed to parking renders a map with no parking markers, no error, and no log line.

The second consequence is the pixel-ratio variants. A sprite must exist at each device pixel ratio the client may request, conventionally 1x and 2x, and each must be rendered from the vector source at that scale. Upscaling the 1x raster produces a 2x sheet whose icons are soft, which is the exact opposite of the reason a high-density variant exists.

The sprite build, and where the style is checked against it A pipeline. Normalised SVG sources feed a rasteriser that runs once per pixel ratio, producing 1x and 2x raster sets from the same vectors. A packer places them into sheets and emits an index for each. A validation step reads the style, extracts every icon name it references, and checks each against the index, failing the build on any name that is absent. SVG sources one canvas, one hook rasterise 1× rasterise 2× pack 1 px gutter sheet + index png and json validate style icon names against index the only place a missing icon is detectable style.json — icon-image references authored separately, drifts independently
The sheet and its index cannot disagree, because one generates the other. The style can disagree with both, which is why the validation step exists.

Step-by-Step Implementation

Step 1: Normalise the sources

from lxml import etree

SVG_NS = "http://www.w3.org/2000/svg"
CANVAS = 24


def normalise_icon(path: str) -> bytes:
    """Force one canvas size and one colour hook onto an icon source."""
    tree = etree.parse(path)
    root = tree.getroot()

    vb = root.get("viewBox")
    if vb is None:
        raise ValueError(f"{path}: no viewBox — the icon has no intrinsic size")
    x, y, w, h = (float(v) for v in vb.replace(",", " ").split())
    if (w, h) != (CANVAS, CANVAS):
        raise ValueError(f"{path}: viewBox is {w}×{h}, expected {CANVAS}×{CANVAS}")

    # Single colour hook: everything paints with currentColor unless it is a
    # deliberate multi-colour icon, which is recorded in the manifest instead.
    for el in root.iter():
        if el.get("fill") not in (None, "none"):
            el.set("fill", "currentColor")
        if el.get("stroke") not in (None, "none"):
            el.set("stroke", "currentColor")

    return etree.tostring(tree)

Raising on a wrong canvas size rather than rescaling is deliberate. An icon delivered at 32 × 32 among a 24 × 24 set was drawn at a different optical weight, and silently scaling it produces a marker whose stroke weight does not match its neighbours — a defect that is obvious on the map and invisible in the build log.

Step 2: Rasterise once per pixel ratio

import cairosvg


def rasterise(svg_bytes: bytes, size_px: int, ratio: int) -> bytes:
    """Render an icon at `size_px * ratio`, from the vector, never by upscaling."""
    return cairosvg.svg2png(
        bytestring=svg_bytes,
        output_width=size_px * ratio,
        output_height=size_px * ratio,
    )

The whole point is the second argument being applied at render time. A build that produces the 1x sheet and then resamples it to 2x satisfies every structural check — the sheet exists, the index is consistent, the dimensions are right — and delivers soft icons to exactly the high-density displays that motivated the variant.

Step 3: Pack with a gutter

from PIL import Image
import io
import math


def pack(icons: dict, cell: int, gutter: int = 1) -> tuple:
    """Pack rendered icons into one sheet; return (image, index)."""
    names = sorted(icons)                       # deterministic layout
    stride = cell + gutter
    cols = max(1, math.isqrt(len(names)))
    rows = math.ceil(len(names) / cols)

    sheet = Image.new("RGBA", (cols * stride + gutter, rows * stride + gutter))
    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(icons[name])), (x, y))
        index[name] = {"x": x, "y": y, "width": cell, "height": cell}
    return sheet, index

Two details earn their place. Sorting the names makes the layout deterministic, so an unchanged icon set produces a byte-identical sheet and the CDN cache is not invalidated by a rebuild. The one-pixel transparent gutter prevents bilinear sampling at the edge of one icon from bleeding a neighbour’s pixels into it, which appears as a faint coloured fringe on rotated or scaled markers.

Step 4: Size symbols across zoom by area

Readers judge a proportional symbol by its area, not its radius. A symbol that should read as twice the magnitude needs twice the area, which is a factor of about 1.41 on the linear dimension — the same reasoning applied to hierarchy tiers in Visual Hierarchy in Code.

def size_stops(base_px: float, zooms=(6, 10, 14, 18),
               growth_per_zoom: float = 1.18) -> list:
    """Interpolation stops for icon size across zoom, growing by area."""
    z0 = zooms[0]
    return [[z, round(base_px * growth_per_zoom ** ((z - z0) / 4), 2)] for z in zooms]

Expressing the curve as a handful of stops rather than a continuous exponential is what keeps both ends of the zoom range sane. An exponential tuned to look right at zoom 12 produces sub-pixel icons at zoom 4 and icons larger than the tile at zoom 20; explicit stops let the ends be clamped where a reader can still use them.

Continuous growth versus clamped stops across the zoom range Symbol pixel size plotted against zoom from 4 to 20. The continuous exponential falls below two pixels at zoom 5, where the icon is no longer identifiable, and exceeds ninety pixels at zoom 20, where a single marker covers a fifth of the tile. The clamped stop curve holds between eight and forty pixels across the whole range. 0 px 48 px 96 px zoom level 4 8 12 16 20 below 8 px an icon is not identifiable continuous clamped stops Both curves are identical around zoom 12, where the exponential was tuned. They diverge at exactly the zoom levels nobody previews.
The two curves agree where the parameter was chosen and disagree everywhere else, which is the general hazard of fitting a continuous function to a single sample point.

Step 5: Validate the style against the index

import json
import re

ICON_REF = re.compile(r'"icon-image"\s*:\s*"([^"]+)"')


def validate_style(style_path: str, index_path: str) -> None:
    """Fail the build if the style references an icon the sprite lacks."""
    style_text = open(style_path, encoding="utf-8").read()
    index = json.load(open(index_path, encoding="utf-8"))

    referenced = set(ICON_REF.findall(style_text))
    missing = sorted(referenced - set(index))
    unused = sorted(set(index) - referenced)

    if missing:
        raise ValueError(f"style references icons absent from the sprite: {missing}")
    if unused:
        print(f"note: {len(unused)} icon(s) in the sprite are unused: {unused[:5]}")

Missing icons fail the build; unused icons are reported and tolerated, because an icon set shared across several styles will always have entries one style does not use. The asymmetry is deliberate: one of these produces a map with holes in it, and the other produces a slightly larger download.

The one-pixel gutter, and what happens without it Two packing layouts. Without a gutter, adjacent icon cells touch and bilinear sampling at an icon's edge pulls colour from its neighbour, drawn as a fringe along the shared border. With a one-pixel transparent gutter, sampling reaches only transparent pixels and each icon draws cleanly. A note records that the gutter costs about eight per cent of sheet area at a 24-pixel cell. no gutter shaded bands = neighbour bleed on any scaled or rotated marker 1 px transparent gutter sampling reaches only transparency so each icon draws clean-edged costs ~8% of sheet area at a 24 px cell Sort names before packing as well: a deterministic layout keeps an unchanged icon set byte-identical across rebuilds, so the CDN copy survives.
The fringe only appears once a marker is scaled or rotated, which is why it survives a review of the sprite sheet itself and shows up on the map.

Performance Optimization Patterns

Cache rasterisation by source hash. Rendering two hundred SVGs at two ratios is the slow part of the build and it is pure: the same source bytes at the same scale always give the same PNG. Key a cache on the source hash and the ratio, and a build that changed one icon re-renders one icon.

Keep the layout deterministic. Sorting icon names before packing means an unchanged set produces a byte-identical sheet, so its URL hash does not change and no client re-downloads it. A packing order derived from directory iteration invalidates the sprite on every build for no reason.

Ship the sheet as a versioned, immutable URL. Because the index and the sheet must agree, they should be fetched as one versioned pair — the same argument for versioned tile URLs made in Tile Cache and Invalidation. A cached sheet paired with a fresher index draws the wrong rectangles, which renders as icons sliced in half.

Keep the sheet under the texture limit. Some GPUs refuse textures beyond 4096 pixels on a side. A 24-pixel icon set at 2x fits roughly 6 500 icons in that budget, which is ample — but a set built at 64 pixels and 3x hits the limit at around 400, and the failure appears as a blank icon layer on specific devices only.

Common Pitfalls and Debugging

Markers sit a few pixels off their feature. An anchor mismatch: the icon was drawn as a pin whose point is at the bottom, while the style anchors at centre. Record the anchor in the manifest per icon and emit it into the style’s icon-anchor, rather than relying on convention.

Icons render with a faint coloured fringe. No gutter between packed cells, so sampling at an icon’s edge pulls in the neighbour. One transparent pixel is enough.

One icon looks heavier than the rest. A source that was not on the shared canvas and got rescaled. This is why the normaliser raises instead of scaling.

Everything works at 1x and the icon layer is blank at 2x. The 2x index was generated but the 2x sheet was not uploaded, or vice versa. Validate that both files exist and that the index’s declared pixelRatio matches the sheet’s dimensions before publishing.

A theme change requires regenerating every sprite. Icons carry baked colours. Convert to currentColor and let the renderer recolour, which is the same inheritance argument made in Theme Inheritance Systems.

Frequently Asked Questions

Why does a sprite icon look blurry at 2x even though the sheet is 2x?

The usual cause is that the 2x sheet was produced by upscaling the 1x raster instead of re-rendering the vector at double resolution. Check the generator: it must invoke the rasteriser twice against the same SVG bytes with different output dimensions, and the two runs must be independent. The second common cause is source geometry sitting on half-pixel boundaries at the 1x size — a one-unit stroke centred on a coordinate ending in .5 straddles two pixels and renders soft at 1x while looking crisp at 2x, which reads as the 1x variant being wrong. Aligning the source geometry to the pixel grid at 1x fixes both variants at once.

How should symbol size relate to zoom level?

Through an explicit interpolation over a small number of zoom stops. Readers judge magnitude by area, so a symbol that should read as twice as important needs twice the area, which is a factor of about 1.41 on the linear dimension rather than 2. Define stops at a handful of zoom levels, clamp both ends, and let the renderer interpolate between them. A continuous exponential fitted to look correct in the middle of the range produces sub-pixel icons at the low end and markers larger than a tile at the high end, and those are precisely the zoom levels nobody previews before shipping.

What happens when a style references an icon the sprite does not contain?

Most renderers omit the symbol and log nothing that a pipeline can observe. The tile renders, the features are present in the vector data, the layer is enabled, and the icons simply are not drawn. Because there is no error to catch at runtime, the mismatch has to be caught at build time by cross-checking every icon-image reference in the style against the sprite index. That check is a dozen lines and it is the only thing standing between an icon rename and a map that quietly stops showing ferry terminals.

Should icons carry their own colour or inherit it from the style?

Inherit wherever the renderer allows it. An icon with a baked fill must be regenerated for every theme, so a light and a dark theme double the artefact set and every new theme doubles it again. Shipping monochrome icons that paint with currentColor and letting the theme supply the colour keeps one sheet for all themes, which is the same argument the token cascade in Theme Inheritance Systems makes about every other style property. Genuinely multi-colour icons — a national flag, a hazard pictogram with regulated colours — are the exception, and they should be recorded as such in the manifest so the recolouring step skips them rather than flattening them.

How large can a sprite sheet safely be?

Keep it within 4096 pixels on a side, which is the texture limit some GPUs still enforce and beyond which the icon layer fails to render on those devices with no visible error anywhere else. At a 24-pixel canvas and a 2x ratio that budget holds several thousand icons, so it is rarely a constraint in practice; it becomes one quickly for a set drawn at 64 pixels and shipped at 3x. If a set genuinely needs to exceed the limit, split it into several sheets by layer group rather than raising the dimensions, because a renderer that fails the texture upload gives no diagnostic that points at the size.

Conclusion

A sprite pipeline is small — a few hundred lines — and it earns its place by making two things impossible: an icon whose optical weight does not match its set, and a style that references an icon nobody built. Both are silent failures in every renderer worth using, and both are trivially detectable at build time given a normalised source set and an index to check against. Treat the icon set as a compiled, versioned artefact with its own tests, and the map stops acquiring missing markers that nobody notices until a reader asks where the ferry terminals went.


Back to Programmatic Map Styling and Label Automation