Theme Inheritance Systems for Automated Cartographic Design
When a mapping agency manages dozens of regional products — topographic base maps, thematic overlays, accessibility-compliant variants, dark and light exports — maintaining style consistency across all of them by hand becomes untenable. A single palette change propagates through hundreds of hand-edited JSON files, and one missed update produces brand-inconsistent output in production. Theme inheritance systems solve this by making cartographic styling declarative and compositional: you define shared visual tokens once in a base schema and let a resolver propagate them automatically to every downstream product.
This page covers the full engineering path from schema design through recursive merge logic, Pydantic validation, and CI/CD integration — producing a pipeline that resolves any number of derivative map themes from a single authoritative base without duplicating style data.
Prerequisites and Environment Configuration
Pinned dependencies for a reproducible resolver environment:
Python >=3.11
pyyaml ==6.0.1
deepmerge ==1.1.1
pydantic ==2.7.1
Install with:
pip install pyyaml==6.0.1 deepmerge==1.1.1 pydantic==2.7.1
Beyond Python packages, ensure:
- A target style specification. The MapLibre GL Style Specification or OGC Styled Layer Descriptor (SLD) defines what property names and value types are valid. Your Pydantic schema must be derived from whichever spec you target.
- Version-controlled theme files. Every base and intermediate theme lives in Git. Leaf themes are generated at build time, never committed. This ensures that the canonical source of truth is always the hierarchy of
.yamlfiles, not the resolved output. - Headless rendering target. A QGIS Server instance,
maplibre-gl-native, or ageopandas/matplotlibpipeline that can ingest the resolved JSON and produce a PNG or vector tile set for snapshot comparison in CI.
Conceptual Foundation: Directed Acyclic Graph Resolution
A deterministic inheritance system is a directed acyclic graph (DAG) over style configurations. Each node is a YAML file; each directed edge is an extends reference pointing from child to parent. Resolution is a post-order tree traversal: load the root, recurse into each parent, then deep-merge the child onto the parent result.
Three structural rules keep the system tractable:
- No cycles. A child cannot directly or transitively reference itself. Enforce this with a visited-node set during traversal.
- Child wins on conflict. When a property exists in both parent and child, the child’s value always wins. This makes overrides predictable and prevents parent updates from accidentally stomping intentional child customisations.
- Arrays are position-keyed, not appended. MapLibre GL
layersarrays are ordered by draw priority. A naiveappendmerge reorders the draw stack. Merge arrays by matching onidfield, then apply property overrides positionally.
The critical difference from a shallow dict.update() is that a deep merge descends into every nested dict. The paint, layout, and filter objects inside each layer entry must be merged recursively, not replaced wholesale. deepmerge.always_merger handles nested dicts correctly out of the box but treats arrays as append-only — the array-by-id strategy below replaces that default.
Step-by-Step Implementation
Step 1 — Define the base schema
Create themes/base.yaml. Every renderable property must have an explicit value here. Omitting a property forces every child to redeclare it, which defeats the purpose of inheritance.
# themes/base.yaml
version: 8
name: base
sources:
openmaptiles:
type: vector
url: "mbtiles://{data}"
glyphs: "{fontstack}/{range}.pbf"
sprite: "/sprites/default"
background_color: "#f8f4f0"
layers:
- id: background
type: background
paint:
background-color: "#f8f4f0"
- id: water
type: fill
source: openmaptiles
source-layer: water
paint:
fill-color: "#a8c8e8"
fill-opacity: 1
- id: road-primary
type: line
source: openmaptiles
source-layer: transportation
filter: ["==", "class", "primary"]
paint:
line-color: "#d4a843"
line-width: 2.5
typography:
label_font: ["Noto Sans Regular", "Open Sans Regular"]
halo_color: "#ffffff"
halo_width: 1.2
Step 2 — Author an intermediate theme
# themes/topographic.yaml
extends: base
name: topographic
layers:
- id: background
paint:
background-color: "#eae6df"
- id: water
paint:
fill-color: "#7ab8d4"
typography:
halo_width: 1.5
The child only declares what changes. All unmentioned layers and typography properties resolve from base.
Step 3 — Author a leaf theme
# themes/high_contrast.yaml
extends: topographic
name: high_contrast
layers:
- id: background
paint:
background-color: "#ffffff"
- id: road-primary
paint:
line-color: "#000000"
line-width: 3.0
typography:
halo_color: "#ffffff"
halo_width: 2.0
Leaf themes inherit from an intermediate, adding only the accessibility-specific overrides. They are never used as parents.
Step 4 — Implement the resolver
import yaml
import os
from deepmerge import always_merger
from pydantic import BaseModel, ValidationError, field_validator
from typing import Any
class TypographySchema(BaseModel):
label_font: list[str]
halo_color: str
halo_width: float
@field_validator("halo_color")
@classmethod
def validate_hex(cls, v: str) -> str:
if not (v.startswith("#") and len(v) in (4, 7)):
raise ValueError(f"Expected CSS hex color, got: {v!r}")
return v
class StyleSchema(BaseModel):
version: int
name: str
layers: list[dict[str, Any]]
typography: TypographySchema
class ThemeResolver:
"""Recursively resolves a theme DAG, returning a flat validated style dict."""
def __init__(self, base_dir: str):
self.base_dir = base_dir
self._cache: dict[str, dict] = {}
self._visited: set[str] = set()
def resolve(self, theme_name: str) -> dict:
if theme_name in self._cache:
return self._cache[theme_name]
if theme_name in self._visited:
raise RuntimeError(f"Circular dependency detected at: {theme_name!r}")
self._visited.add(theme_name)
config_path = os.path.join(self.base_dir, f"{theme_name}.yaml")
with open(config_path, "r", encoding="utf-8") as fh:
child_config: dict = yaml.safe_load(fh)
parent_key = child_config.get("extends")
if parent_key:
parent_config = self.resolve(parent_key)
# Deep-merge: parent is the base, child overrides on top.
resolved = always_merger.merge(
dict(parent_config), # copy to avoid mutating cache
child_config,
)
# Merge layer arrays by id so child overrides individual layer
# properties without replacing the entire layer list.
resolved["layers"] = self._merge_layers(
parent_config.get("layers", []),
child_config.get("layers", []),
)
else:
resolved = child_config
resolved.pop("extends", None)
self._cache[theme_name] = resolved
self._visited.discard(theme_name)
return resolved
@staticmethod
def _merge_layers(
parent_layers: list[dict], child_layers: list[dict]
) -> list[dict]:
"""Merge child layer overrides into parent layers, keyed on layer id."""
parent_map = {layer["id"]: dict(layer) for layer in parent_layers}
for child_layer in child_layers:
lid = child_layer["id"]
if lid in parent_map:
# Recurse into nested paint/layout dicts specifically.
for sub_key in ("paint", "layout", "filter"):
if sub_key in child_layer and sub_key in parent_map[lid]:
parent_map[lid][sub_key] = always_merger.merge(
dict(parent_map[lid][sub_key]),
child_layer[sub_key],
)
child_layer = {
k: v for k, v in child_layer.items() if k != sub_key
}
parent_map[lid].update(child_layer)
else:
parent_map[lid] = child_layer
# Preserve parent draw order; append new child-only layers at end.
parent_order = [layer["id"] for layer in parent_layers]
new_ids = [lid for lid in parent_map if lid not in parent_order]
return [parent_map[lid] for lid in parent_order + new_ids]
Step 5 — Validate and run
resolver = ThemeResolver("themes/")
try:
flat_style = resolver.resolve("high_contrast")
validated = StyleSchema(**flat_style)
print(f"Resolved {validated.name!r}: {len(validated.layers)} layers")
except RuntimeError as exc:
print(f"DAG error: {exc}")
except ValidationError as exc:
print(f"Schema violation: {exc}")
Complete Working Code Example
The script below wires the resolver into a batch export loop, writing one resolved JSON file per leaf theme for ingestion by a downstream headless renderer:
"""
batch_resolve.py
Resolves every leaf theme in themes/ and writes flat JSON to dist/styles/.
Leaf themes are identified by the absence of an 'extends' target that
is itself extended by another theme — in practice, maintain an explicit
LEAF_THEMES list to avoid scanning the directory graph.
"""
import json
import os
import yaml
from pathlib import Path
from theme_resolver import ThemeResolver, StyleSchema
from pydantic import ValidationError
THEMES_DIR = "themes"
OUTPUT_DIR = "dist/styles"
LEAF_THEMES = ["high_contrast", "dark_web", "print_atlas"]
def resolve_all_leaf_themes() -> dict[str, dict]:
resolver = ThemeResolver(THEMES_DIR)
results: dict[str, dict] = {}
for theme_name in LEAF_THEMES:
try:
flat = resolver.resolve(theme_name)
validated = StyleSchema(**flat)
results[theme_name] = validated.model_dump()
except (RuntimeError, ValidationError, FileNotFoundError) as exc:
print(f"[ERROR] {theme_name}: {exc}")
return results
def write_resolved_styles(styles: dict[str, dict]) -> None:
out_path = Path(OUTPUT_DIR)
out_path.mkdir(parents=True, exist_ok=True)
for theme_name, style in styles.items():
dest = out_path / f"{theme_name}.json"
with open(dest, "w", encoding="utf-8") as fh:
json.dump(style, fh, indent=2, ensure_ascii=False)
print(f"[OK] Wrote {dest} ({len(style['layers'])} layers)")
if __name__ == "__main__":
resolved = resolve_all_leaf_themes()
write_resolved_styles(resolved)
print(f"Done: {len(resolved)}/{len(LEAF_THEMES)} themes resolved successfully.")
Run this as a build step before invoking any renderer. The dist/styles/ directory then contains deterministic, validated JSON files that can be consumed by QGIS Server’s MAP parameter, passed to maplibre-gl-native-node, or fed into a matplotlib pipeline via a custom JSON-to-matplotlib style adapter.
Performance Optimization Patterns
Cache resolved intermediates in memory, not on disk. The ThemeResolver._cache dict avoids re-reading and re-merging parent chains when multiple leaf themes share the same intermediate. For 10 leaf themes all extending the same topographic parent, the parent is resolved once and reused nine times. Disk I/O is the dominant cost for small style files (< 1 MB); in-memory caching typically reduces batch resolution time by 60-80 % at scale.
Limit inheritance depth to three levels. Each additional level adds a full file-read and merge pass. A three-level DAG (base → intermediate → leaf) adds roughly 2-5 ms of overhead per theme on warm cache. A five-level chain with 50 overrides per level can push 20-40 ms per theme and makes merge-order bugs significantly harder to trace. If a fourth level seems necessary, pre-flatten the base+intermediate pair during the build step.
Pre-resolve and serialise during CI, not at request time. For web mapping applications that serve styles over HTTP, resolve and write the JSON files during the CI build, not on each HTTP request. A 200 ms resolution chain is acceptable in a batch build; it is unacceptable in a tile server hot path. Serve the pre-resolved JSON as a static asset from a CDN.
Use orjson for large style serialisation. The standard json.dump is adequate for files under 500 KB, but complex multi-layer GL styles can exceed 2 MB. Replacing json.dump with orjson.dumps typically cuts serialisation time by 3-5x for large style objects with deep nesting.
Common Pitfalls and Debugging
Style bleed from shallow merges. The most common failure is using Python’s built-in dict.update() instead of a recursive merge. dict.update() replaces the entire paint sub-dict if any paint property appears in the child, silently erasing all parent paint properties not mentioned in the child. Switch to deepmerge.always_merger and write a regression test that verifies the parent’s fill-opacity survives a child that only overrides fill-color.
Array append causing draw-order corruption. deepmerge.always_merger’s default array strategy appends child entries to the parent list, producing duplicate layer ids and incorrect draw order. The _merge_layers() helper above replaces this with id-keyed positional merging. Verify by asserting len({l['id'] for l in resolved['layers']}) == len(resolved['layers']) — a set equality check catches duplicate ids immediately.
Circular extends chains. A misconfigured theme where topographic extends high_contrast and high_contrast extends topographic produces infinite recursion. The _visited set in ThemeResolver.resolve() raises a RuntimeError on the second visit. Add this check as a pre-flight test: resolve every leaf theme in CI and fail the build on any RuntimeError.
Missing font stacks in the headless environment. A theme may declare ["Noto Sans Bold", "Open Sans Bold"] in its typography token, but the headless renderer only has Noto Sans Regular installed. The resolved style is structurally valid, but labels render as invisible boxes. Validate font availability by cross-referencing style['glyphs'] and the resolved text-font arrays against the renderer’s installed glyph set before export.
Pydantic v2 model mutation. Pydantic v2 returns immutable model instances by default. Calling validated.model_dump() is correct; mutating validated.layers[0]['paint'] after construction raises a ValidationError. If you need to patch a resolved style post-validation (e.g. to inject a runtime sources URL), call model_dump() first, mutate the plain dict, then re-validate if schema safety matters.
Conclusion
A well-structured theme inheritance system transforms cartographic styling from a copy-paste operation into a deterministic, testable pipeline. The three-tier DAG (base → intermediate → leaf), combined with deepmerge for recursive property merging, id-keyed array merging for layer ordering, and Pydantic for schema enforcement, covers the vast majority of production cartographic use cases. The batch resolver script integrates cleanly into a CI workflow: resolve, validate, write, render, compare snapshots. With this foundation in place, extending the system to support dark/light mode transitions, accessibility variants, or per-region brand overrides requires only authoring thin new leaf theme files — no changes to the resolution engine itself.
For the specific challenge of managing luminance token swaps across dark and light exports without duplicating layer definitions, see Implementing Dark and Light Theme Inheritance for Web Maps.
Related
- Implementing Dark and Light Theme Inheritance for Web Maps — token-driven dark/light switching without duplicating layer definitions
- Dynamic Legend Generation — keep legend entries in sync when theme overrides change layer colours or symbol sizes
- Label Collision Avoidance Algorithms — typography-scale changes in child themes directly affect label placement and halo radii
- Rule-Based Styling Engines — combine attribute-driven rule evaluation with theme inheritance for fully automated feature symbology