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 foundand 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.
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.
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.
Edge cases or notes
pdf.fonttypemust be set beforesavefig, not before the figure is created.- Mathtext uses its own font set;
mathtext.fontsetis a separate setting. usetex=Truebypasses 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.fonttypematters for EPS, which some journals still request.- Outlines are the last resort: identical everywhere, and no longer searchable or editable.
Internal links
- How to export a map at print quality โ where these settings live
- How to build a print-ready map layout in Matplotlib โ the rcParams block
- Fixing map text that is too small in the exported file โ the other export failure
- Fixing addresses that break on accents and encoding โ when the data, not the font, is broken
- How to build a reusable map style module โ pinning the font stack
- Fixing a map export that is hundreds of megabytes โ the size side of export
- Accessible maps explained: contrast, text and alternatives โ text that has to be readable
- Reproducible GIS environments explained โ fonts as an environment dependency
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.