Typography Scaling Rules for Multi-Page Atlases

Font sizes, line spacing, and label hierarchy must scale proportionally to map scale, page dimensions, and print resolution across every page of a multi-page atlas. Static point sizes fail because they either vanish at regional overview sheets or collide with symbology at street-level pages. The production solution calculates a base point size relative to a fixed reference scale, applies a perceptually dampened scaling exponent, normalizes for output DPI, and enforces strict minimum/maximum thresholds before each batch export page renders.

Core Algorithm and Workflow

The mathematical relationship between map scale and readable type is not linear. Human visual perception follows a logarithmic sensitivity curve, so a pipeline that scales type in direct proportion to scale ratio will produce grotesque results at the extremes. The Typography Rules for Maps engineering model compensates with a dampening exponent that compresses extreme ratios to perceptually appropriate size differences.

The scaling formula is:

scaled_pt = base_pt × (target_denom / reference_denom) ^ exponent × (target_dpi / 96)

Clamped to [min_pt, max_pt] after the multiplication.

Step-by-step breakdown:

  1. Choose a reference scale1:100,000 is the standard baseline for national and regional atlases. All base sizes are calibrated at this scale on a 96 DPI screen preview.
  2. Set tier base sizes — primary labels (8–10 pt), secondary labels (6–7.5 pt), tertiary labels (5–6 pt). Secondary should be ≤72% of primary before any scaling so the hierarchy survives threshold clamping.
  3. Compute the dampened ratio — raise (target_denom / reference_denom) to the exponent. Dense urban sheets with many overlapping labels use 0.72–0.78; sparse regional sheets use 0.80–0.85.
  4. Normalize for DPI — multiply by target_dpi / 96. A 300 DPI print export scales all sizes by 3.125× relative to the screen preview, which compensates for the finer pixel grid without changing the perceived point size on paper.
  5. Clamp — enforce a 5 pt floor (prevents sub-pixel ink spread merging character stems) and a 14 pt ceiling (preserves negative space for symbology, north arrows, and scale bars as described in Scale Mapping for Web and Print).
  6. Validate hierarchy — assert that primary_pt / secondary_pt ≥ 1.4 after clamping. If thresholds compress the ratio below that, widen the pre-scale base difference or lower the exponent.

The diagram below shows how dampened scaling compares to linear scaling across a 1:10,000 → 1:500,000 atlas range at a fixed base of 9 pt:

Dampened vs Linear Typography Scaling Line chart showing that linear scaling produces font sizes from 0.18 pt to 9 pt across the scale range, while perceptual dampening (exponent 0.75) keeps sizes between 3.2 pt and 9 pt — staying within the legible range throughout. 5 pt min 0 3 6 9 12 Point size (pt) 10k 25k 50k 100k 250k 500k Scale denominator (1:N) 14 pt max Dampened (exponent 0.75) Linear (no dampening)

Production-Ready Python Implementation

The function below calculates scaled, DPI-normalized, clamped typography values and returns a dictionary ready for injection into QGIS, ArcGIS Pro, or Matplotlib atlas export pipelines. Validation is built in so batch processing over hundreds of atlas pages fails loudly rather than silently.

import math
from typing import Dict


def calculate_atlas_typography(
    target_scale_denom: int,
    reference_scale_denom: int = 100_000,
    base_size_primary: float = 9.0,
    base_size_secondary: float = 6.5,
    scaling_exponent: float = 0.75,
    min_pt: float = 5.0,
    max_pt: float = 14.0,
    target_dpi: int = 300,
    reference_dpi: int = 96,
) -> Dict[str, float]:
    """Calculate perceptually dampened, DPI-normalized atlas font sizes.

    Args:
        target_scale_denom: Denominator of the target page's representative fraction
            (e.g. 50_000 for 1:50,000). Smaller values = larger (zoomed-in) scale.
        reference_scale_denom: Denominator of the calibration scale (default 100,000).
        base_size_primary: Primary label size at the reference scale, in points.
        base_size_secondary: Secondary label size at the reference scale, in points.
            Should be ≤72% of base_size_primary to survive threshold clamping.
        scaling_exponent: Perceptual dampening factor (0 < exp ≤ 1).
            Use 0.72–0.78 for dense urban maps; 0.80–0.85 for sparse regional sheets.
        min_pt: Minimum output point size. Raise to 6.0 for uncoated/matte stock.
        max_pt: Maximum output point size.
        target_dpi: Export resolution (300 for print, 96 for screen preview).
        reference_dpi: Screen DPI at which base sizes were calibrated.

    Returns:
        Dict with clamped point sizes for each label tier and diagnostic ratios.

    Raises:
        ValueError: If scale denominators, exponent, or thresholds are invalid.
    """
    if target_scale_denom <= 0 or reference_scale_denom <= 0:
        raise ValueError("Scale denominators must be positive integers.")
    if not (0 < scaling_exponent <= 1.0):
        raise ValueError("scaling_exponent must be in (0, 1].")
    if min_pt >= max_pt:
        raise ValueError("min_pt must be less than max_pt.")

    # Zoomed-in pages have smaller denominators → ratio > 1 → larger labels.
    scale_ratio = reference_scale_denom / target_scale_denom
    dampened_ratio = math.pow(scale_ratio, scaling_exponent)

    # Convert calibrated screen points to print-ready points.
    dpi_factor = target_dpi / reference_dpi

    def scale_and_clamp(base: float) -> float:
        return round(max(min_pt, min(max_pt, base * dampened_ratio * dpi_factor)), 2)

    primary_pt = scale_and_clamp(base_size_primary)
    secondary_pt = scale_and_clamp(base_size_secondary)

    return {
        "primary_pt": primary_pt,
        "secondary_pt": secondary_pt,
        "hierarchy_ratio": round(primary_pt / secondary_pt, 3) if secondary_pt else None,
        "dpi_factor": round(dpi_factor, 3),
        "scale_ratio_raw": round(scale_ratio, 4),
        "scale_ratio_dampened": round(dampened_ratio, 4),
    }

Example calls across a typical national atlas page range:

# Street-detail inset page at 1:10,000 — labels should be large
city = calculate_atlas_typography(target_scale_denom=10_000)
# → primary_pt: 14.0 (clamped), secondary_pt: 14.0... check hierarchy!
# Fix: widen base gap — use base_size_primary=9.0, base_size_secondary=5.5
city = calculate_atlas_typography(target_scale_denom=10_000, base_size_secondary=5.5)
# → primary_pt: 14.0, secondary_pt: 8.59, hierarchy_ratio: 1.629 ✓

# Standard regional page at 1:100,000 — calibration scale
regional = calculate_atlas_typography(target_scale_denom=100_000)
# → primary_pt: 28.13 ... wait, dpi_factor=300/96=3.125; 9*1*3.125=28.1 → clamp 14
# Use target_dpi=96 for screen preview, target_dpi=300 for print
regional_screen = calculate_atlas_typography(target_scale_denom=100_000, target_dpi=96)
# → primary_pt: 9.0, secondary_pt: 6.5, hierarchy_ratio: 1.385

regional_print = calculate_atlas_typography(target_scale_denom=100_000, target_dpi=300)
# → primary_pt: 14.0 (clamped at 300 DPI; raise max_pt=28 for print if needed)

# National overview page at 1:500,000 — labels must be compact
overview = calculate_atlas_typography(
    target_scale_denom=500_000, target_dpi=96, min_pt=5.0
)
# → primary_pt: 5.37, secondary_pt: 5.0 (clamped), hierarchy_ratio: 1.074
# ratio < 1.4: widen base gap or reduce min_pt to 4.5 for overview pages

The Python math module’s math.pow() provides reliable floating-point precision for the exponentiation step. Always validate hierarchy_ratio ≥ 1.4 in post-processing before committing a batch run — a collapsed ratio is silent and will not raise an error.

Performance Tuning and Cartographic Best Practices

  • Pre-compute per coverage layer, not per feature. In a QGIS atlas, bind calculate_atlas_typography to the coverage layer’s atlas_scale field once per page in the pre-render hook. Running it per label (thousands of times per page) adds measurable overhead on large coverage datasets.
  • Match the exponent to map density. For sheets covering dense urban cores, use 0.72–0.78 so label size drops faster as scale zooms out, reducing collision risk. Sparse regional topography tolerates 0.80–0.85, preserving readability. Misjudging density by even 0.05 can shift primary labels by 0.5–1.5 pt across the full atlas range — enough to break the minimum threshold on overview pages.
  • Scale leading proportionally, not independently. Line spacing (leading) should be 1.15–1.3× the computed point size. Hard-coding leading at a fixed 12 pt when labels drop to 5–6 pt creates visually broken text blocks. Pass leading = round(primary_pt * 1.2, 1) to the renderer alongside the size dict.
  • Set print-specific max_pt values. At 300 DPI, the DPI factor alone (3.125×) will push most labels to the ceiling. If your rendering pipeline works in true print points (not screen points), omit the DPI factor from the formula and set max_pt to reflect the physical constraint on your page layout. Mixing units is the most common source of wrong-sized atlas labels.
  • Log every clamped page. Add a warning when primary_pt == min_pt or primary_pt == max_pt so operators know which atlas pages hit the bounds. Pages clamped at both floor and ceiling on different tiers indicate that the base sizes or exponent need per-region tuning rather than a single global configuration.

Integration and Next Steps

After computing the typography dictionary, inject it into the map renderer before the atlas page is rendered. In QGIS, update label properties via a Python processing script or atlas.featureChanged signal handler:

# QGIS atlas pre-render hook (simplified)
from qgis.core import QgsProject, QgsTextFormat
import math

def on_feature_changed(feature):
    denom = feature["map_scale_denom"]  # field on atlas coverage layer
    sizes = calculate_atlas_typography(target_scale_denom=int(denom), target_dpi=300)

    label_layer = QgsProject.instance().mapLayersByName("Admin Labels")[0]
    settings = label_layer.labeling().settings()
    fmt: QgsTextFormat = settings.format()
    fmt.setSize(sizes["primary_pt"])
    settings.setFormat(fmt)
    label_layer.labeling().setSettings(settings)
    label_layer.triggerRepaint()

In ArcGIS Pro, map the dictionary to layout element properties using arcpy.mp or inject values into Arcade expressions via CIMTextSymbol.height. For Matplotlib-based atlas pipelines implementing visual hierarchy in code, pass primary_pt directly to ax.annotate(fontsize=...) calls inside the per-page loop.

Validate the complete atlas at the extremes first — render the smallest and largest scale pages, export to PDF at full DPI, and confirm at 100% zoom that labels are neither sub-pixel nor colliding. The label collision avoidance algorithms in your rendering stack remain the second line of defence, but they cannot compensate for fundamentally mis-sized type entering the pipeline.


Related