Typography Rules for Maps
Automated map generation pipelines regularly succeed at geometry rendering and symbology but fail at the final text pass: labels collide, contrast deteriorates against raster basemaps, and font sizes remain fixed across map scales. This page gives GIS engineers and Python automation builders a deterministic, production-tested framework for automating every stage of map typography — from attribute-driven font hierarchy through spatial conflict resolution, contrast enforcement, and export validation.
Prerequisites and Environment Configuration
Before implementing automated typography rules, confirm these baseline requirements:
- Python 3.10+ with
geopandas>=0.14,shapely>=2.0,matplotlib>=3.8,adjustText>=0.8,pyproj>=3.6, andfreetype-py>=2.4installed in an isolated virtual environment. Pin all versions inrequirements.txtto prevent rendering drift between CI nodes. - Metric CRS: All input layers must be projected to a metric coordinate reference system — EPSG:3857 (Web Mercator) for web map exports, or a UTM zone for high-accuracy print work. Typography placement calculations degrade in unprojected geographic coordinates because degree-unit distances compress non-linearly toward the poles. The Projection Selection Algorithms page provides a decision framework matched to your map extent and distortion tolerances.
- Font directory: A centralized font path with verified licensing and fallback chains. Automated pipelines fail silently when fonts are missing on CI render nodes — configure
matplotlibrcwith an explicitfont.familyand a POSIX-compatible fallback stack before first run. - Output specification: Defined DPI, physical dimensions, and colour profile (sRGB for screen, CMYK for print). These values feed directly into the font-size calculation functions below. The relationship between DPI and label legibility is covered in depth in DPI and Resolution Management.
- Clean attribute tables: Label fields must be sanitised — no null values, no mixed capitalisation, no encoding errors — before reaching the renderer. Run
gdf[label_col].fillna("").str.strip()as a minimum pre-flight check.
Conceptual Foundation
Map typography automation rests on three interlocking principles: attribute-driven hierarchy, metric-space bounding boxes, and iterative spatial conflict resolution.
Attribute-driven hierarchy encodes cartographic type conventions (large bold face for capitals, small light face for hamlets) as a mapping function from a numeric priority field to font size, weight, and colour. Rather than hardcoding tiers, use numpy.interp or quantile breaks so the mapping adapts to the data’s natural distribution. This mirrors the approach that Visual Hierarchy in Code uses for symbol rank ordering across full feature stacks.
Metric-space bounding boxes are the unit of collision detection. Before any label is placed, its rendered width and height must be estimated in CRS units (metres or feet), not points or pixels. The conversion is:
box_width_m = (text_width_pt / 72) * 0.0254 * dpi * map_units_per_metre
Calculating these boxes upfront — using matplotlib.textpath.TextPath or PIL.ImageFont.getbbox — lets the collision resolver work entirely in map space without touching the renderer until final output.
Iterative spatial conflict resolution processes features from highest to lowest priority. Each candidate position is checked against a shapely.STRtree of already-placed bounding boxes. On a hit, the algorithm tries alternative anchor positions (cardinal offsets, rotation, leader-line offset). On total failure, it suppresses the label and logs the feature ID. The full STRtree construction and tiered fallback strategies are covered in Label Collision Avoidance Algorithms.
Step-by-Step Implementation
Step 1 — Validate CRS and sanitise attributes
import geopandas as gpd
from pyproj import CRS
def validate_input(gdf: gpd.GeoDataFrame, label_col: str, priority_col: str,
target_epsg: int = 3857) -> gpd.GeoDataFrame:
if gdf.crs is None:
raise ValueError("GeoDataFrame has no CRS; assign one before processing.")
target = CRS.from_epsg(target_epsg)
if not gdf.crs.equals(target):
gdf = gdf.to_crs(epsg=target_epsg)
gdf = gdf.copy()
gdf[label_col] = gdf[label_col].fillna("").str.strip()
gdf = gdf[gdf[label_col] != ""].copy()
gdf = gdf.dropna(subset=[priority_col]).copy()
return gdf.sort_values(by=[priority_col, label_col], ascending=[False, True])
Sorting by (priority_col, label_col) is non-negotiable for reproducibility. If two features share a priority weight, alphabetical label order breaks ties deterministically — without this, adjustText’s solver may resolve ties differently on each run, causing CI output drift.
Step 2 — Compute font sizes from priority field
import numpy as np
def assign_font_sizes(gdf: gpd.GeoDataFrame, priority_col: str,
min_pt: float = 7.0, max_pt: float = 14.0) -> gpd.GeoDataFrame:
p = gdf[priority_col].values
p_min, p_max = p.min(), p.max()
if p_min == p_max:
gdf = gdf.copy()
gdf["font_size"] = (min_pt + max_pt) / 2
return gdf
gdf = gdf.copy()
gdf["font_size"] = np.interp(p, [p_min, p_max], [min_pt, max_pt])
return gdf
For population-scaled labels, replace np.interp with log normalisation: np.interp(np.log1p(p), [np.log1p(p_min), np.log1p(p_max)], [min_pt, max_pt]). This prevents capital cities from dwarfing all other labels while still establishing a clear visual hierarchy, as recommended by the Visual Hierarchy in Code principles for proportional symbol scaling.
Step 3 — Estimate bounding boxes in map units
from matplotlib.textpath import TextPath
from matplotlib.font_manager import FontProperties
def estimate_bbox_map_units(text: str, font_size_pt: float,
dpi: int, map_units_per_metre: float,
font_family: str = "DejaVu Sans") -> tuple[float, float]:
"""Return (width, height) in CRS map units for a rendered label."""
fp = FontProperties(family=font_family, size=font_size_pt)
tp = TextPath((0, 0), text, prop=fp)
bb = tp.get_extents()
# TextPath coordinates are in points; convert to metres, then to map units
pt_to_m = 0.0254 / 72
w_m = bb.width * pt_to_m * dpi
h_m = bb.height * pt_to_m * dpi
return w_m * map_units_per_metre, h_m * map_units_per_metre
Cache this function’s output by (text, font_size_pt, font_family) using functools.lru_cache. Recalculating glyph extents per feature is the single largest performance bottleneck in label pipelines with more than ~2,000 unique strings.
Step 4 — Spatial conflict resolution with STRtree
from shapely.geometry import box as shapely_box
from shapely.strtree import STRtree
def place_labels_with_collision(gdf: gpd.GeoDataFrame, label_col: str,
bbox_col: str = "label_bbox",
halo_radius: float = 500.0) -> gpd.GeoDataFrame:
"""
Iterative priority-ordered placement with STRtree collision detection.
halo_radius is in CRS units (metres for EPSG:3857).
"""
placed_boxes: list = []
tree: STRtree | None = None
results = []
for _, row in gdf.iterrows():
cx, cy = row.geometry.centroid.x, row.geometry.centroid.y
w, h = row[bbox_col]
# Inflate box by halo to account for visual halo rendering
candidate = shapely_box(cx - w/2 - halo_radius, cy - h/2 - halo_radius,
cx + w/2 + halo_radius, cy + h/2 + halo_radius)
collision = False
if tree is not None:
hits = tree.query(candidate)
collision = any(placed_boxes[i].intersects(candidate) for i in hits)
if not collision:
placed_boxes.append(candidate)
tree = STRtree(placed_boxes)
results.append({"feature_id": row.name, "placed": True,
"x": cx, "y": cy, "label": row[label_col],
"font_size": row["font_size"]})
else:
results.append({"feature_id": row.name, "placed": False,
"x": None, "y": None, "label": row[label_col],
"font_size": row["font_size"]})
return gpd.GeoDataFrame(results)
Rebuilding STRtree after every insertion is O(n log n) per step, giving O(n² log n) overall for dense datasets. See the Performance Optimization Patterns section for batch-rebuild strategies that recover near-linear scaling.
Step 5 — Contrast enforcement before render
Automated labels must maintain a minimum contrast ratio against the underlying basemap raster. Implement a per-label luminance check before rendering. This is the same WCAG 2.2 relative-luminance formula applied in Accessibility Sync in Cartography for full-layer colour compliance:
from PIL import Image
import numpy as np
def check_label_contrast(basemap_img: Image.Image, x_px: int, y_px: int,
w_px: int, h_px: int,
label_rgb: tuple[int, int, int] = (0, 0, 0)) -> float:
"""Return WCAG relative luminance contrast ratio for a label region."""
region = np.array(basemap_img.crop((x_px, y_px, x_px + w_px, y_px + h_px)))
# Mean background colour under label bounding box
bg_mean = region.mean(axis=(0, 1))[:3] / 255.0
label_norm = np.array(label_rgb) / 255.0
def relative_luminance(c: np.ndarray) -> float:
c = np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
return float(0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2])
L1 = relative_luminance(label_norm)
L2 = relative_luminance(bg_mean)
L_light, L_dark = max(L1, L2), min(L1, L2)
return (L_light + 0.05) / (L_dark + 0.05)
When the returned ratio falls below 4.5 (WCAG AA for normal text), apply a white or black halo sized to the halo radius used in the collision resolver.
Complete Working Code Example
import geopandas as gpd
import matplotlib.pyplot as plt
from adjustText import adjust_text
from pyproj import CRS
import numpy as np
from shapely.geometry import box as shapely_box
from shapely.strtree import STRtree
import functools
# ─── Font metric cache ─────────────────────────────────────────────────────────
from matplotlib.textpath import TextPath
from matplotlib.font_manager import FontProperties
@functools.lru_cache(maxsize=4096)
def _cached_bbox(text: str, size: float, family: str) -> tuple[float, float]:
fp = FontProperties(family=family, size=size)
bb = TextPath((0, 0), text, prop=fp).get_extents()
return bb.width, bb.height
def generate_map_labels(
gdf: gpd.GeoDataFrame,
label_col: str,
priority_col: str,
crs_target: int = 3857,
min_pt: float = 7.0,
max_pt: float = 14.0,
dpi: int = 300,
halo_radius: float = 500.0,
output_path: str = "map_output.png",
font_family: str = "DejaVu Sans",
) -> str:
"""
Full automated typography pipeline:
CRS validation → attribute hierarchy → bounding-box estimation
→ STRtree conflict resolution → contrast check → export.
"""
# 1. CRS
if gdf.crs is None:
raise ValueError("GeoDataFrame has no CRS.")
if not gdf.crs.equals(CRS.from_epsg(crs_target)):
gdf = gdf.to_crs(epsg=crs_target)
# 2. Sanitise and sort
gdf = gdf.copy()
gdf[label_col] = gdf[label_col].fillna("").str.strip()
gdf = gdf[gdf[label_col] != ""].dropna(subset=[priority_col]).copy()
gdf = gdf.sort_values(by=[priority_col, label_col], ascending=[False, True])
# 3. Font sizes
p = gdf[priority_col].values
p_min, p_max = p.min(), p.max()
if p_min == p_max:
gdf["font_size"] = (min_pt + max_pt) / 2.0
else:
gdf["font_size"] = np.interp(p, [p_min, p_max], [min_pt, max_pt])
# 4. Estimate bounding boxes (metres at DPI)
PT_TO_M = 0.0254 / 72
gdf["bbox_w"] = gdf.apply(
lambda r: _cached_bbox(r[label_col], r["font_size"], font_family)[0] * PT_TO_M * dpi,
axis=1,
)
gdf["bbox_h"] = gdf.apply(
lambda r: _cached_bbox(r[label_col], r["font_size"], font_family)[1] * PT_TO_M * dpi,
axis=1,
)
# 5. STRtree conflict resolution
placed_boxes: list = []
tree = None
keep_mask: list[bool] = []
for _, row in gdf.iterrows():
cx, cy = row.geometry.centroid.x, row.geometry.centroid.y
cand = shapely_box(
cx - row["bbox_w"] / 2 - halo_radius,
cy - row["bbox_h"] / 2 - halo_radius,
cx + row["bbox_w"] / 2 + halo_radius,
cy + row["bbox_h"] / 2 + halo_radius,
)
if tree is not None:
hits = tree.query(cand)
collision = any(placed_boxes[i].intersects(cand) for i in hits)
else:
collision = False
if not collision:
placed_boxes.append(cand)
tree = STRtree(placed_boxes)
keep_mask.append(True)
else:
keep_mask.append(False)
placed = gdf[keep_mask].copy()
# 6. Render
fig, ax = plt.subplots(dpi=dpi, figsize=(12, 9))
gdf.plot(ax=ax, color="lightgray", edgecolor="darkgray", linewidth=0.5)
texts = [
ax.text(
row.geometry.centroid.x, row.geometry.centroid.y, row[label_col],
fontsize=row["font_size"], ha="center", va="center",
fontfamily=font_family,
bbox=dict(facecolor="white", alpha=0.75, edgecolor="none", pad=1.5),
)
for _, row in placed.iterrows()
]
adjust_text(
texts, ax=ax,
expand_points=(1.3, 1.3),
arrowprops=dict(arrowstyle="-", color="gray", lw=0.4),
)
ax.axis("off")
plt.savefig(output_path, bbox_inches="tight", dpi=dpi, transparent=False)
plt.close()
return output_path
Performance Optimization Patterns
-
Batch STRtree rebuilds — Rebuilding the index after every single insertion is O(n² log n). Instead, collect the first 200 placements, build the initial tree, then rebuild every 500 inserts. This reduces tree construction overhead by ~60% on datasets of 10,000+ features.
-
LRU-cached font metrics —
_cached_bboxwithlru_cache(maxsize=4096)means each unique(text, size, family)triplet is computed once. On a typical city dataset where ~40% of labels are unique strings, cache hit rates reach 60–70%, roughly halving bounding-box computation time. -
Viewport pre-filtering — Before collision detection, drop all features whose centroid lies outside the export extent. For tiled exports this alone can remove 80% of candidates from the conflict resolver.
-
Parallel tile processing — Partition the geodataframe into spatial tiles using
gdf.sindex.intersection(tile_bbox). Process each tile independently withconcurrent.futures.ProcessPoolExecutor, then merge tile outputs while checking cross-boundary collisions with a narrow buffer strip. This maps cleanly to the Scale Mapping for Web and Print batch generation patterns for multi-scale exports.
Common Pitfalls and Debugging
CRS mismatch produces distorted label spacing. If gdf.crs is EPSG:4326 (geographic degrees), bounding boxes estimated in metres will be orders of magnitude off. Always call gdf.crs.is_projected and raise early if it returns False.
Halo radius ignored in collision detection. Text rendering applies halos that visually extend beyond the raw glyph bounds. If halo_radius is left at 0.0, the collision resolver will declare placements valid that look overlapping in the exported image. Inflate every candidate box by the actual halo width before querying the tree.
Non-deterministic solver output. adjustText processes labels in the order they appear in the input list. If you do not sort by (priority_col, label_col) before calling adjust_text(), ties in priority weight resolve differently per run, making CI output non-reproducible.
PDF backend recalculates layout at target DPI. Labels that look correctly spaced in a 96 DPI screen preview may overlap in a 300 DPI PDF export. Call fig.set_dpi(target_dpi) and rerun the placement logic at the export DPI, not the display DPI.
Missing glyphs render as tofu boxes. CJK, Arabic, and Cyrillic label fields require system fonts that ship those codepoints. Preprocess Arabic and Hebrew strings with python-bidi to apply Unicode Bidirectional Algorithm reordering. Validate your matplotlibrc font stack against the actual codepoints in your label attribute table before deploying to CI.
Conclusion
Automated map typography is an engineering problem, not an aesthetic one. By encoding visual hierarchy as attribute-driven font functions, estimating bounding boxes in metric space before rendering, and running a priority-ordered STRtree conflict resolver, Python pipelines can produce publication-quality labelled maps without manual intervention. For multi-page atlas workflows where labels must adapt across varying extents and paper sizes, extend these patterns with the per-page scaling strategies in Typography Scaling Rules for Multi-Page Atlases.
Related
- Typography Scaling Rules for Multi-Page Atlases — per-page font size adaptation for atlas-scale exports
- WCAG Contrast Checking for Map Layers — applying the WCAG 2.2 luminance formula across raster and vector layer stacks