Fixing Missing or Substituted Fonts in an Exported Map PDF

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.savefig("map.pdf")
pdffonts map.pdf      # every row should say TrueType/Type 1C and 'yes' under 'emb'

Measured on a small test figure, the change from Type 3 to TrueType took the file from 7.3 kB to 11.6 kB. Four kilobytes to remove the most common publisher rejection.

Triage table of five font symptoms in an exported PDF and their causes.
None of these are visible on the machine that made the file.

Step-by-step solution

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 in particular is frequently absent on Linux, and the resulting substitution changes the metrics โ€” so labels that were placed without overlap now collide.

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})
>>> missing_glyphs("Mรผnchen ร…lesund ลรณdลบ ฤฐzmir ไธญๆ–‡")
['ไธญ', 'ๆ–‡']

CJK, Arabic and Indic scripts need a font that covers them; the standard Latin fonts do not.

6. Verify the exported file, not the figure

pdffonts map.pdf
name                        type         emb sub uni
--------------------------- ------------ --- --- ---
ABCDEE+DejaVuSans           TrueType     yes yes yes

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

Two panels showing how font substitution turns collision-free labels into colliding ones.
The placement guarantee was computed against metrics the other machine does not have.

Code examples

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

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

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

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.

Explanation

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. The measured cost was 4.3 kB on a small figure, and 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 a one-line figure: 1.4 kB with text, 7.2 kB with fifteen <path> elements. 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.

Checklist of five scripts to test a font against, with CJK marked as needing another family.
Mojibake is a data problem; a missing glyph is a font problem. Different fixes.

Edge cases or notes

  • pdf.fonttype must be set before savefig, not before the figure is created.
  • 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.