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:
- Choose a reference scale —
1:100,000is the standard baseline for national and regional atlases. All base sizes are calibrated at this scale on a 96 DPI screen preview. - 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. - Compute the dampened ratio — raise
(target_denom / reference_denom)to the exponent. Dense urban sheets with many overlapping labels use0.72–0.78; sparse regional sheets use0.80–0.85. - Normalize for DPI — multiply by
target_dpi / 96. A 300 DPI print export scales all sizes by3.125×relative to the screen preview, which compensates for the finer pixel grid without changing the perceived point size on paper. - Clamp — enforce a
5 ptfloor (prevents sub-pixel ink spread merging character stems) and a14 ptceiling (preserves negative space for symbology, north arrows, and scale bars as described in Scale Mapping for Web and Print). - Validate hierarchy — assert that
primary_pt / secondary_pt ≥ 1.4after 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:
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_typographyto the coverage layer’satlas_scalefield 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.78so label size drops faster as scale zooms out, reducing collision risk. Sparse regional topography tolerates0.80–0.85, preserving readability. Misjudging density by even0.05can shift primary labels by0.5–1.5 ptacross the full atlas range — enough to break the minimum threshold on overview pages. - Scale leading proportionally, not independently. Line spacing (
leading) should be1.15–1.3×the computed point size. Hard-coding leading at a fixed12 ptwhen labels drop to5–6 ptcreates visually broken text blocks. Passleading = round(primary_pt * 1.2, 1)to the renderer alongside the size dict. - Set print-specific
max_ptvalues. 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 setmax_ptto 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_ptorprimary_pt == max_ptso 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
- Typography Rules for Maps — the parent cluster covering font hierarchies, spatial conflict resolution, and contrast enforcement for map labels.
- Scale Mapping for Web and Print — CRS-aware representative fraction calculation and DPI handling for both screen and print export pipelines.
- Label Collision Avoidance Algorithms — spatial indexing and conflict resolution strategies that work alongside correctly sized atlas labels.
- Implementing Visual Hierarchy with Matplotlib — zorder, alpha, and font-weight controls for automated figure exports.
- Automated Cartographic Design Fundamentals — the parent section covering projection selection, colour theory, accessibility, and scale-dependent rendering.