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 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 = 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.
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.
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.
When the setting is read, mathtext and usetex
pdf.fonttypeis read atsavefig, not when the figure is created โ Example 4 changed it in between and got TrueType.- Set
pdf.fonttypeandps.fonttypetogether. With matplotlib 3.11, PDF Type 3 plus PostScript Type 42 fails to save any label beyond Latin-1:Mรกlagasaved,ลรณdลบraisedValueError: bytes must be in range(0, 256). - Invisible characters travel with names. Natural Earth's
Granadaends 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.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.