Font metrics: which families plotpress can measure

A figure is laid out before anything draws its glyphs: SVG emits <text> and lets the viewer rasterize. So plotpress has to predict how wide text will be, and it predicts from bundled advance-width tables.

It bundles the base-14 metric families – Helvetica, Times and Courier, each in regular / bold / italic / bold-italic – plus DejaVu Sans. A family that resolves to one of those is measured exactly. A family that resolves to none of them is measured as Helvetica, and that is where layout goes wrong: the figure still renders, but legend boxes and axis margins were sized for the wrong font.

The chart below shows how much room each family actually needs, as a percentage of what the layout reserved. Anything past 100% overflows its box; anything well under wastes margin.

import numpy as np
import plotpress

# Percentages are hard-coded so this example renders identically everywhere,
# including doc builders that have none of these fonts installed. The measured
# families sit at 100% by construction: plotpress reserves space using the very
# table that describes them. The fallback figures are their true widths relative
# to Helvetica, which is what gets reserved on their behalf.
FAMILIES = [
    ("Arial", 99.9, True),
    ("Liberation Sans", 99.9, True),
    ("Courier New", 100.0, True),
    ("Times New Roman", 100.0, True),
    ("DejaVu Sans", 100.0, True),
    ("Arial Narrow", 81.9, False),
    ("Verdana", 115.5, False),
    ("Arial Black", 125.8, False),
]
SAFE_BAND = 3.0          # within a few % the difference is invisible

names = [n for n, _, _ in FAMILIES]
pct = np.array([p for _, p, _ in FAMILIES])
measured = [m for _, _, m in FAMILIES]
y = np.arange(len(names))

fig, ax = plotpress.subplots(figsize=(8.0, 4.6))

# Shade the band where a family is effectively interchangeable with what was
# reserved for it.
ax.axvspan(100 - SAFE_BAND, 100 + SAFE_BAND, color="#2ca02c", alpha=0.13,
           label="fits the reserved space")

colors = ["#2ca02c" if m else "#d62728" for m in measured]
ax.barh(y, pct - 100, height=0.6, left=100, color=colors, edgecolor="#ffffff",
        linewidth=0.8)
ax.axvline(100, color="#333333", linewidth=1.2, linestyle="-")

for i, (p, m) in enumerate(zip(pct, measured)):
    over = p - 100
    label = "measured" if m else f"{over:+.0f}%"
    ax.text(p + (1.5 if over >= 0 else -1.5), i, label,
            ha="left" if over >= 0 else "right", va="center", fontsize=9)

ax.set_yticks(y)
ax.set_yticklabels(names)
ax.set_xlim(60, 165)
ax.set_xlabel("width actually needed, as % of the space plotpress reserved")
ax.set_title("Green: a bundled table measures it. Red: measured as Helvetica.")
ax.grid(True)
ax.legend(loc="lower right")
fig.tight_layout()
plot 01 font metrics

Live figure — pick a tool, then zoom, pan, point-pick or annotate. Nothing is active until a tool is selected.

View this figure’s Vega export ↗ — the raw JSON spec, rendered live by a real Vega engine.

View this figure’s Vega-Lite export ↗ — the raw JSON spec(s), rendered live by a real Vega-Lite engine.

Reading it

  • Arial and Liberation Sans are metric-compatible with Helvetica by design – they agree to within 0.1%, so the default stack "Helvetica, Arial, sans-serif" is accurate everywhere.

  • Courier New, Times New Roman and DejaVu Sans each resolve to their own bundled table, so they are measured exactly too. Courier New used to be the worst case here, needing ~46% more room than was reserved.

  • Verdana, Arial Black and Arial Narrow have proprietary metrics that match nothing bundled, so they fall back to Helvetica and stay wrong – the first two overflow, the third wastes margin. Ugly rather than broken.

If you want a different look, prefer a family in the green group. Everything else renders, but you should expect to hand-tune figsize and margins.

Weight matters too

Bold is not a free variation on regular: Helvetica-Bold runs 5-9% wider on realistic label strings. plotpress bundles the bold tables and measures the elements it draws bold – the legend title and suptitle – with them.

The second limitation: pixel rounding

Even a perfectly compatible font drifts a little, because renderers round each glyph’s advance to a whole pixel while the metrics table is continuous. The error is largest when glyphs are only a few pixels wide, and it washes out as text gets bigger – which is why PNG export supersamples (scale=2 by default) before downsampling.

We can model it exactly, with no font files involved: ask text_width for each glyph and round it, which is what a renderer does.

from plotpress.fonts import text_width

SAMPLES = ["1.002e5", "y axis label", "a series label", "-0.5", "Wwwiii",
           "0.002", "temperature (K)", "Title of the plot", "1000", "-1e5"]


def quantized_width(text, size):
    """Width once each glyph advance is rounded to a whole pixel."""
    return sum(round(text_width(ch, size)) for ch in text)


scales = [1, 2, 4]
mean_err, worst_err = [], []
for scale in scales:
    px = 9 * scale                     # tick labels are 9px at scale=1
    e = [abs(quantized_width(s, px) - text_width(s, px)) / text_width(s, px) * 100
         for s in SAMPLES]
    mean_err.append(np.mean(e))
    worst_err.append(max(e))

x = np.arange(len(scales))
fig2, ax2 = plotpress.subplots(figsize=(7.0, 3.6))
ax2.bar(x - 0.18, mean_err, width=0.34, label="mean")
ax2.bar(x + 0.18, worst_err, width=0.34, label="worst case")
ax2.set_xticks(x)
ax2.set_xticklabels([f"scale={s} ({9 * s}px)" for s in scales])
ax2.set_ylabel("width error (%)")
ax2.set_title("Pixel rounding, at the scales fig.save() actually renders")
ax2.grid(True)
ax2.legend()
fig2.tight_layout()
plot 01 font metrics

Live figure — pick a tool, then zoom, pan, point-pick or annotate. Nothing is active until a tool is selected.

View this figure’s Vega export ↗ — the raw JSON spec, rendered live by a real Vega engine.

View this figure’s Vega-Lite export ↗ — the raw JSON spec(s), rendered live by a real Vega-Lite engine.

The default (scale=2) already reduces this to ~0.1% – it is effectively gone, and it never causes overlap in any case. Only scale=1 shows it at all. So of the two limitations on this page, only the family mismatch is worth designing around.

(The rounding model matches real rendering exactly at 12px and above; below that, font hinting departs from linear scaling and it becomes an estimate – which is why the scale=1 bars are indicative rather than precise.)

Total running time of the script: (0 minutes 0.123 seconds)

Gallery generated by Sphinx-Gallery