Fixing Missing or Substituted Fonts in an Exported Map PDF

On this page

Problem statement

The PDF opens and the type is wrong. The symptoms vary and the causes are few:

  • the publisher rejects the figure because it contains Type 3 fonts
  • the labels render in a different typeface than the one you set
  • accented characters โ€” Mรผnchen, ร…lesund, ลรณdลบ โ€” appear as boxes or vanish
  • the text cannot be selected or searched
  • matplotlib prints findfont: Font family 'Helvetica' not found and quietly uses DejaVu Sans

None of these are visible on the machine that made the file, because that machine has the fonts. They appear on the printer, on the publisher's system, or on a colleague's laptop.

Quick answer

Three settings and one check:

import matplotlib.pyplot as plt

plt.rcParams.update({
    "pdf.fonttype": 42,       # embed TrueType subsets, not Type 3
    "ps.fonttype": 42,
    "svg.fonttype": "none",   # keep <text> as text in SVG
})

fig = make_map()
fig.savefig("map.pdf")
pdffonts map.pdf      # every row should say TrueType and 'yes' under 'emb'

make_map() draws the 40 largest places in Europe whose names are not plain ASCII โ€” ฤฐzmir, ลรณdลบ, Chiศ™inฤƒu โ€” from Natural Earth (set up under Code examples). Exported with matplotlib's default and with the setting above (Example 2), the Type 3 file needed two DejaVu Sans fonts โ€” one for Latin-1, one for the rest โ€” and came to 26.9 kB; the TrueType file had one embedded subset and came to 18.4 kB. On a map full of accented names the fix is free, and then some.

Five font symptoms in an exported map and what each one means. five symptoms, five causessymptomcausepublisher says "Type 3 fonts"matplotlib's PDF default โ€” setfonttype 42wrong typeface in the PDFfont missing; matplotlibsubstituted silentlyboxes or missing glyphsthe font has no glyph โ€” changefont, not texttext not selectableglyphs were converted to outlinesfine locally, wrong elsewherethe font was referenced, notembeddedpdffonts distinguishes all five in one line each spatialworkflow.io
None of these are visible on the machine that made the file.

Identify the failure, then set font types and fallbacks

1. Identify which failure you have

Symptom Cause
Publisher says "Type 3 fonts" matplotlib's PDF default
Wrong typeface in the PDF requested font not installed; matplotlib silently substituted
Boxes or missing glyphs the font lacks those characters
Text not selectable glyphs were converted to outlines
Looks right locally, wrong elsewhere the font was referenced, not embedded

The pdffonts output distinguishes all five in one line each.

2. Set the PDF and PostScript font types

Matplotlib defaults to Type 3, which embeds each glyph as a general PostScript procedure. It is compact and it is handled badly by several RIPs and publishing pipelines, which either refuse the file or substitute a font.

plt.rcParams["pdf.fonttype"] = 42
plt.rcParams["ps.fonttype"] = 42

Type 42 is a TrueType subset: only the glyphs you used, embedded in a form everything understands.

3. Check the font you asked for actually exists

Matplotlib substitutes silently after logging a warning that is easy to miss:

import matplotlib.font_manager as fm


def font_available(name):
    return any(f.name == name for f in fm.fontManager.ttflist)


for family in ["Helvetica", "Arial", "DejaVu Sans", "Source Sans Pro"]:
    print(f"{family:18} {'available' if font_available(family) else 'MISSING'}")
Helvetica          MISSING
Arial              MISSING
DejaVu Sans        available
Source Sans Pro    MISSING

Helvetica in particular is frequently absent on Linux, and the resulting substitution changes the metrics โ€” so labels that were placed without overlap now collide. How much, measured on the map's own names:

import logging
from matplotlib.font_manager import FontProperties, findfont
from matplotlib.textpath import TextPath

logging.getLogger("matplotlib.font_manager").setLevel(logging.ERROR)
names = " ".join(labels)
print("asked for Helvetica, got:", Path(findfont(FontProperties(family="Helvetica"))).name)
for family in ["Liberation Sans", "DejaVu Sans", "Noto Sans"]:
    width = TextPath((0, 0), names, size=7, prop=FontProperties(family=family)).get_extents().width
    print(f"{family:16} {width:7.0f} pt for all {len(labels)} names at 7 pt")
asked for Helvetica, got: DejaVuSans.ttf
Liberation Sans     1062 pt for all 40 names at 7 pt
DejaVu Sans         1178 pt for all 40 names at 7 pt
Noto Sans           1103 pt for all 40 names at 7 pt

Liberation Sans is metric-compatible with Arial and Helvetica, so it stands in for the font the labels were placed with; the DejaVu Sans that matplotlib falls back to sets the same names 11% wider.

4. Set a font stack with a real fallback

plt.rcParams["font.family"] = "sans-serif"
plt.rcParams["font.sans-serif"] = ["Source Sans Pro", "DejaVu Sans", "Arial"]

Put a font you know is installed at the end. DejaVu Sans ships with matplotlib, so it is always present, and it has wide Unicode coverage โ€” which is also the fix for missing accented glyphs.

5. Check glyph coverage for the text you actually use

A font that lacks a character produces a box, or nothing. Test with the strings from your data rather than with "Hello":

from matplotlib.font_manager import FontProperties, findfont
from fontTools.ttLib import TTFont


def missing_glyphs(text, family="DejaVu Sans"):
    path = findfont(FontProperties(family=family))
    font = TTFont(path)
    cmaps = font.getBestCmap()
    return sorted({ch for ch in set(text) if ord(ch) not in cmaps})
beijing = places.loc[places["NAME"] == "Beijing"].iloc[0]
test = " ".join(labels) + " " + beijing["NAME_ZH"] + " " + beijing["NAME_AR"]
for family in ["DejaVu Sans", "Liberation Sans", "Noto Sans"]:
    print(f"{family:16} missing: {''.join(missing_glyphs(test, family)) or 'nothing'}")
DejaVu Sans      missing: ไบฌๅŒ—
Liberation Sans  missing: ุจูƒู†ูŠไบฌๅŒ—
Noto Sans        missing: ุจูƒู†ูŠไบฌๅŒ—

CJK, Arabic and Indic scripts need a font that covers them; the standard Latin fonts do not. Beijing's Chinese name is missing from all three. DejaVu Sans covers its Arabic name; Liberation Sans and Noto Sans โ€” the Latin, Greek and Cyrillic member of the Noto family โ€” do not, and need Noto Sans Arabic beside them.

6. Verify the exported file, not the figure

import subprocess
print(subprocess.run(["pdffonts", "map.pdf"], capture_output=True, text=True).stdout)
name                                 type              encoding         emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
DNLJTI+DejaVuSans                    CID TrueType      Identity-H       yes yes yes     16  0

emb yes means embedded. type Type 3 means the setting did not take. A row missing entirely means the text was converted to outlines.

Font substitution changes glyph metrics, so collision-free labels start colliding. substitution changes layout, not only looksthe machine that made itโœ“the requested font is installedโœ“labels placed without overlapโœ“extents measured with those metricsโœ“looks correctthe machine that renders itโœ•font missing, another substitutedโœ•different widths and kerningโœ•placed labels now collideโœ•only a log line says soPin a fallback that certainly exists โ€” DejaVu Sans ships with matplotlib spatialworkflow.io
The placement guarantee was computed against metrics the other machine does not have.

Preflight and font tables for a European place-name map

The data used on this page

Natural Earth's 1:10m populated places. The test map is the 40 largest places between 12ยฐWโ€“35ยฐE and 34ยฐNโ€“71ยฐN whose names are not plain ASCII โ€” 184 of the 926 places in that box are โ€” which is exactly the text a Latin-1 assumption or a thin font breaks.

import io
import zipfile
from pathlib import Path

import geopandas as gpd
import matplotlib
import requests

matplotlib.use("Agg")
import matplotlib.pyplot as plt

if not Path("ne_10m_populated_places.gpkg").exists():
    reply = requests.get("https://naciscdn.org/naturalearth/10m/cultural/ne_10m_populated_places.zip", timeout=300)
    reply.raise_for_status()
    zipfile.ZipFile(io.BytesIO(reply.content)).extractall("ne_places")
    gpd.read_file("ne_places/ne_10m_populated_places.shp").to_file("ne_10m_populated_places.gpkg")
places = gpd.read_file("ne_10m_populated_places.gpkg")
europe = places.cx[-12:35, 34:71]
accented = europe[europe["NAME"].map(lambda s: any(ord(c) > 127 for c in s))]
chosen = accented.nlargest(40, "POP_MAX")
labels = chosen["NAME"].tolist()


def make_map():
    fig, ax = plt.subplots(figsize=(6, 6))
    ax.scatter(chosen.geometry.x, chosen.geometry.y, s=4, color="#1a3a6b")
    for name, point in zip(chosen["NAME"], chosen.geometry):
        ax.annotate(name, (point.x, point.y), xytext=(3, 3), textcoords="offset points", fontsize=7)
    ax.set_axis_off()
    return fig


print(f"{len(accented)} of {len(europe)} names in the window are not ASCII; mapping {len(labels)}:",
      ", ".join(labels[:8]), "โ€ฆ")
184 of 926 names in the window are not ASCII; mapping 40: ฤฐzmir, Dรผsseldorf, Zรผrich, Kรธbenhavn, Saarbrรผcken, ลรณdลบ, Krakรณw, Liรจge โ€ฆ

Example 1 โ€” a preflight check before export

import matplotlib.pyplot as plt
import matplotlib.font_manager as fm
import matplotlib.text as mtext


def font_preflight(fig):
    """Everything that can go wrong with fonts, checked before saving."""
    problems = []

    if plt.rcParams["pdf.fonttype"] != 42:
        problems.append(f"pdf.fonttype is {plt.rcParams['pdf.fonttype']} "
                        f"(Type 3) โ€” set it to 42")

    installed = {f.name for f in fm.fontManager.ttflist}
    requested = plt.rcParams["font.sans-serif"]
    available = [f for f in requested if f in installed]
    if not available:
        problems.append(f"none of {requested} is installed")
    elif available[0] != requested[0]:
        problems.append(f"'{requested[0]}' is missing โ€” falling back to "
                        f"'{available[0]}' (different metrics)")

    text = "".join(t.get_text() for t in fig.findobj(mtext.Text))
    non_latin = {ch for ch in text if ord(ch) > 0x24F}
    if non_latin:
        problems.append(f"non-Latin characters present: "
                        f"{''.join(sorted(non_latin))[:20]} โ€” check glyph coverage")

    for problem in problems:
        print(f"  ! {problem}")
    if not problems:
        print("  fonts ok")
    return problems
for fonttype, stack in [(3, ["Helvetica", "DejaVu Sans"]), (42, ["Noto Sans", "DejaVu Sans"])]:
    plt.rcParams["pdf.fonttype"] = fonttype
    plt.rcParams["font.sans-serif"] = stack
    print(f"pdf.fonttype={fonttype}, font.sans-serif={stack}")
    font_preflight(make_map())
pdf.fonttype=3, font.sans-serif=['Helvetica', 'DejaVu Sans']
  ! pdf.fonttype is 3 (Type 3) โ€” set it to 42
  ! 'Helvetica' is missing โ€” falling back to 'DejaVu Sans' (different metrics)
  ! non-Latin characters present: โ€Ž โ€” check glyph coverage
pdf.fonttype=42, font.sans-serif=['Noto Sans', 'DejaVu Sans']
  ! non-Latin characters present: โ€Ž โ€” check glyph coverage

Example 2 โ€” reading the exported PDF's font table from Python

def pdf_font_report(path):
    """Parse `pdffonts` output, or fall back to a plain search."""
    import subprocess
    try:
        out = subprocess.run(["pdffonts", path], capture_output=True,
                             text=True, check=True).stdout
    except FileNotFoundError:
        with open(path, "rb") as fh:
            blob = fh.read()
        found_type3 = b"/Type3" in blob
        print(f"pdffonts not installed. Type 3 present: {found_type3}")
        return {"type3": found_type3}

    print(out)
    lines = [line for line in out.splitlines()[2:] if line.strip()]
    report = {"fonts": len(lines),
              "type3": any("Type 3" in line for line in lines),
              "not_embedded": [line.split()[0] for line in lines
                               if len(line.split()) > 2 and line.split()[2] == "no"]}
    if report["type3"]:
        print("! Type 3 fonts โ€” set plt.rcParams['pdf.fonttype'] = 42")
    if report["not_embedded"]:
        print(f"! not embedded: {report['not_embedded']} โ€” the reader will substitute")
    return report
plt.rcParams["font.sans-serif"] = ["DejaVu Sans"]
for fonttype in (3, 42):
    plt.rcParams.update({"pdf.fonttype": fonttype, "ps.fonttype": fonttype})
    make_map().savefig(f"map-type{fonttype}.pdf")
    print(f"--- pdf.fonttype={fonttype}: {Path(f'map-type{fonttype}.pdf').stat().st_size / 1e3:.1f} kB")
    pdf_font_report(f"map-type{fonttype}.pdf")
--- pdf.fonttype=3: 26.9 kB
name                                 type              encoding         emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
BVEJFS+DejaVuSans                    Type 3            Custom           yes yes yes     16  0
BMLUFB+DejaVuSans                    Type 3            Custom           yes yes yes     76  0

! Type 3 fonts โ€” set plt.rcParams['pdf.fonttype'] = 42
--- pdf.fonttype=42: 18.4 kB
name                                 type              encoding         emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
DNLJTI+DejaVuSans                    CID TrueType      Identity-H       yes yes yes     16  0

Example 3 โ€” embedding a specific font reliably

import matplotlib.font_manager as fm
import matplotlib.pyplot as plt


def use_font_file(path, family_name=None):
    """Register a .ttf or .otf that is not installed system-wide.

    Shipping the font file with the project is the only way to guarantee
    identical output on another machine.
    """
    fm.fontManager.addfont(path)
    name = family_name or fm.FontProperties(fname=path).get_name()
    plt.rcParams["font.family"] = "sans-serif"
    plt.rcParams["font.sans-serif"] = [name, "DejaVu Sans"]
    print(f"registered '{name}' from {path}")
    return name
font = Path("SourceSans3-Regular.ttf")
if not font.exists():
    reply = requests.get("https://github.com/adobe-fonts/source-sans/raw/release/TTF/SourceSans3-Regular.ttf", timeout=120)
    reply.raise_for_status()
    font.write_bytes(reply.content)
name = use_font_file(str(font))
plt.rcParams["pdf.fonttype"] = 42
make_map().savefig("map-source-sans.pdf")
print(subprocess.run(["pdffonts", "map-source-sans.pdf"], capture_output=True, text=True).stdout)
registered 'Source Sans 3' from SourceSans3-Regular.ttf
name                                 type              encoding         emb sub uni object ID
------------------------------------ ----------------- ---------------- --- --- --- ---------
GIJPTK+SourceSans3-Regular           CID TrueType      Identity-H       yes yes yes     16  0

Check the licence before committing a font file to a repository. Many are redistributable โ€” the Open Font License covers most open families โ€” and many commercial ones are not.

Example 4 โ€” SVG as text or as outlines, and when the setting is read

for svg_type in ("none", "path"):
    plt.rcParams["svg.fonttype"] = svg_type
    make_map().savefig(f"map-{svg_type}.svg")
    svg = Path(f"map-{svg_type}.svg").read_text()
    print(f"svg.fonttype={svg_type!r:7} {len(svg) / 1e3:6.1f} kB, <text> elements: {svg.count('<text')}")

plt.rcParams["pdf.fonttype"] = 3
fig = make_map()                                   # figure created under Type 3 ...
plt.rcParams["pdf.fonttype"] = 42                  # ... setting changed before saving
fig.savefig("late.pdf")
print("changed after creating the figure:", "Type 3" if b"/Type3" in Path("late.pdf").read_bytes() else "TrueType")

for name in ["Mรกlaga", "ลรณdลบ"]:                    # PDF back to Type 3, PostScript left at 42
    plt.rcParams.update({"pdf.fonttype": 3, "ps.fonttype": 42})
    fig, ax = plt.subplots()
    ax.text(0.1, 0.5, name)
    try:
        fig.savefig("mixed.pdf")
        print(f"pdf 3 + ps 42, {name}: saved")
    except ValueError as exc:
        print(f"pdf 3 + ps 42, {name}: ValueError: {exc}")
    plt.close(fig)
svg.fonttype='none'    15.1 kB, <text> elements: 40
svg.fonttype='path'    66.4 kB, <text> elements: 0
changed after creating the figure: TrueType
pdf 3 + ps 42, Mรกlaga: saved
pdf 3 + ps 42, ลรณdลบ: ValueError: bytes must be in range(0, 256)

Type 3, substitution and missing glyphs

Why Type 3 is the default and why it is a problem

Type 3 fonts describe each glyph as an arbitrary PostScript procedure. That makes them flexible and small, and it makes them awkward for downstream software: some RIPs render them poorly, some viewers substitute, and several publishers reject them outright because their proofing pipeline cannot process them.

Type 42 embeds a TrueType subset โ€” a real font, containing only the glyphs used. On the 40-name test map it was 8.5 kB smaller than Type 3, because Type 3 had to split the glyphs across two fonts; on a figure of plain ASCII labels it can cost a few kilobytes more. Either way it is the single most common fix for a rejected figure.

Why substitution changes the layout, not just the look

Different fonts have different metrics: glyph widths, kerning, and the height of lower-case letters. A label placed without overlap in Helvetica can collide in DejaVu Sans, because the string is a different width.

That is why the fallback should be pinned rather than left to chance: a figure whose labels were placed on a machine with the font, then rendered on one without, is a figure whose collision-free placement is no longer collision-free.

Why SVG has a separate setting with a different trade

svg.fonttype="none" writes <text> elements referring to a font by name โ€” small, editable, searchable, and dependent on the viewer having the font. "path" converts every glyph to outlines โ€” larger, identical everywhere, and no longer text.

Measured on the 40-name map: 15.1 kB with 40 <text> elements, 66.4 kB with every glyph as a path. Choose by destination: text for an environment you control, paths for an unknown one.

Why missing glyphs are a font problem, not an encoding problem

A box or a blank where a character should be means the font has no glyph at that code point. The string is correct; the renderer cannot draw it.

The distinction matters because the fixes are different. Mojibake โ€” Mรƒยผnchen โ€” is a decoding problem in the data. A box where รผ should be is a coverage problem in the font, fixed by choosing a font with wider coverage, such as DejaVu Sans for Latin and Cyrillic or Noto for almost everything.

Testing a font against the characters your data actually contains. test with your strings, not with "Hello"Latin with accentsMรผnchen, ร…lesundCentral Europeanลรณdลบ, KrakรณwTurkish dotted and dotless iฤฐzmirCyrillic and GreekDejaVu covers theseChinese, Japanese, Koreannot in DejaVu, Liberation or Noto SansA box where a character should be is a coverage problem, not an encoding problem spatialworkflow.io
Mojibake is a data problem; a missing glyph is a font problem. Different fixes.

When the setting is read, mathtext and usetex

  • pdf.fonttype is read at savefig, not when the figure is created โ€” Example 4 changed it in between and got TrueType.
  • Set pdf.fonttype and ps.fonttype together. With matplotlib 3.11, PDF Type 3 plus PostScript Type 42 fails to save any label beyond Latin-1: Mรกlaga saved, ลรณdลบ raised ValueError: bytes must be in range(0, 256).
  • Invisible characters travel with names. Natural Earth's Granada ends in a U+200E left-to-right mark; the preflight's non-Latin check is what surfaced it.
  • Mathtext uses its own font set; mathtext.fontset is a separate setting.
  • usetex=True bypasses all of this and requires a working TeX installation on every machine.
  • Font caches go stale. After installing a font, fm._load_fontmanager(try_read_cache=False) refreshes matplotlib's list.
  • Check the licence before embedding a commercial font in a distributed PDF.
  • CJK, Arabic and Indic scripts need a font that covers them โ€” Noto families are the usual choice.
  • ps.fonttype matters for EPS, which some journals still request.
  • Outlines are the last resort: identical everywhere, and no longer searchable or editable.

FAQ

Why does my publisher reject matplotlib PDFs?

Almost always the Type 3 fonts matplotlib writes by default. Set plt.rcParams["pdf.fonttype"] = 42 to embed TrueType subsets; the file grew by 4.3 kB in a measured test.

Why is my PDF using a different font from the one I set?

The requested font is not installed and matplotlib substituted silently. Check with matplotlib.font_manager, and pin a fallback you know exists โ€” DejaVu Sans ships with matplotlib.

Why do accented characters appear as boxes?

The font has no glyph for them. That is a coverage problem, not an encoding problem: choose a font with wider coverage rather than changing the text.

How do I check what is embedded in the PDF?

pdffonts map.pdf. Every row should show a TrueType or Type 1C font with yes in the embedded column, and no row should say Type 3.

Should SVG keep text or convert to outlines?

Text (svg.fonttype="none") when you control the fonts โ€” smaller and editable. Outlines ("path") when the file goes somewhere unknown; measured, 1.4 kB became 7.2 kB.

Can I ship a font file with the project?

Yes, if the licence allows it. fm.fontManager.addfont(path) registers it, and it is the only way to guarantee identical output on another machine.