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.

Automated Map Typography Pipeline A four-stage flow diagram showing the automated typography pipeline: (1) Attribute Mapping — priority, class, scale; (2) Bounding-Box Generation — font metrics, DPI, rotation; (3) Conflict Resolution — STRtree index, fallback strategies; (4) Export Validation — contrast, overlap audit, colour profile. 1 · Attribute Mapping priority · class · scale 2 · Bounding-Box Generation font metrics · DPI · rotation 3 · Conflict Resolution STRtree · fallback · suppress 4 · Export Validation contrast · overlap · profile

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, and freetype-py>=2.4 installed in an isolated virtual environment. Pin all versions in requirements.txt to 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 matplotlibrc with an explicit font.family and 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.

STRtree Priority-Ordered Label Placement Diagram showing three label candidates processed in descending priority order. Label A (priority 10) is placed first with no collision. Label B (priority 7) is placed second; its bounding box clears label A. Label C (priority 3) is suppressed because its bounding box intersects the already-placed box for label B. Capital City priority 10 · placed ✓ River Town priority 7 · placed ✓ Hamlet Lane priority 3 · suppressed ✗ collision zone Processing order (highest priority first): 1. Capital City → query STRtree (empty) → place 2. River Town → query STRtree → no hit → place 3. Hamlet Lane → hit → suppress

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

  1. 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.

  2. LRU-cached font metrics_cached_bbox with lru_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.

  3. 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.

  4. Parallel tile processing — Partition the geodataframe into spatial tiles using gdf.sindex.intersection(tile_bbox). Process each tile independently with concurrent.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

Back to Automated Cartographic Design Fundamentals