Skip to content

Support TTC fonts by converting them to TTF in font cache #23293

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions doc/users/next_whats_new/ttc_font_support.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
TTC font collection support
---------------------------

Fonts in a TrueType collection file (TTC) can now be added and used. Internally,
the embedded TTF fonts are extracted and stored in the matplotlib cache
directory. Users upgrading to this version need to rebuild the font cache for
this feature to become effective.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe write how that is done? (I do not really recall myself...)

(Also one may consider having a public method that is directly importable from matplotlib, but that is another question...)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You mean rebuilding the font cache? I think the simplest way to trigger a rebuild is to delete the folder printed by

python -c "import matplotlib; print(matplotlib.get_cachedir())"

Maybe there is a more user-friendly, programmatic way.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regarding the question about the public API, I'm not sure if we want to expose this. It's not directly useful for the end user. If someone wants to use a custom TTC font, they would call the font manager's addfont() with the path to the TTC file. This will automatically extract the TTF and add the created files to the internal list of fonts.

If a user were to use the _split_ttc() function, they would need to use the returned paths and add them to the font manager themself. I don't think that's what we want users to do.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, rebuilding the font cache. I seem to recall that there is a function to rebuild the cache without removing it, but cannot find it now... Googling gives matplotlib.font_manager._rebuild() (and quite a few questions), but that is not a current approach.

Anyway, some sort of info about how to do this (maybe it is in the docs to link to) I would think is useful since probably quite a few users like to do that.

113 changes: 112 additions & 1 deletion lib/matplotlib/font_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from pathlib import Path
import re
import subprocess
import struct
import sys
import threading

Expand Down Expand Up @@ -1099,7 +1100,7 @@ def __init__(self, size=None, weight='normal'):
'Matplotlib is building the font cache; this may take a moment.'))
timer.start()
try:
for fontext in ["afm", "ttf"]:
for fontext in ["afm", "ttf", "ttc"]:
for path in [*findSystemFonts(paths, fontext=fontext),
*findSystemFonts(fontext=fontext)]:
try:
Expand Down Expand Up @@ -1129,6 +1130,9 @@ def addfont(self, path):
font = _afm.AFM(fh)
prop = afmFontProperty(path, font)
self.afmlist.append(prop)
elif Path(path).suffix.lower() == ".ttc":
for ttf_file in _split_ttc(path):
self.addfont(ttf_file)
else:
font = ft2font.FT2Font(path)
prop = ttfFontProperty(font)
Expand Down Expand Up @@ -1473,6 +1477,113 @@ def get_font(filename, hinting_factor=None):
thread_id=threading.get_ident())


def _split_ttc(ttc_path):
"""Split a TTC file into TTF files"""
res = _read_ttc(ttc_path)
ttf_fonts, table_index, table_data = res
out_base = Path(
mpl.get_cachedir(),
os.path.basename(ttc_path) + "-"
)
return _dump_ttf(out_base, ttf_fonts, table_index, table_data)


def _read_ttc(ttc_path):
"""
Read a TTC font collection

Returns an internal list of TTF fonts, table index data, and table
contents.
"""
with open(ttc_path, "rb") as ttc_file:
def read(fmt):
"""Read with struct format"""
size = struct.calcsize(fmt)
data = ttc_file.read(size)
return struct.unpack(fmt, data)

tag, major, minor = read(">4sHH") # ttcf tag and version
if tag != b'ttcf':
_log.warning("Failed to read TTC file, invalid tag: %r", ttc_path)
return [], {}, {}

if major > 2:
_log.info("TTC file format version > 2, parsing might fail: %r",
ttc_path)

num_fonts = read(">I")[0] # Number of fonts
font_offsets = read(f">{num_fonts:d}I") # offsets of TTF font

# Set of tables referenced by any font
table_index = {} # (offset, length): tag, chksum

# List of TTF fonts
ttf_fonts = [] # (version, num_entries, triple, referenced tables)

# Read TTF headers and directory tables
for font_offset in font_offsets:
ttc_file.seek(font_offset)

version = read(">HH") # TTF format version
num_entries = read(">H")[0] # Number of entried in directory table
triple = read(">HHH") # Weird triple, often invalid
referenced_tables = []

for _ in range(num_entries):
tag, chksum, offset, length = read(">IIII")
referenced_tables.append((offset, length))
table_index[(offset, length)] = tag, chksum

ttf_fonts.append((version, num_entries, triple, referenced_tables))

# Read data for all tables
table_data = {}
for (offset, length), (tag, chksum) in table_index.items():
ttc_file.seek(offset)
table_data[(offset, length)] = ttc_file.read(length)

_log.debug("Extracted %d tables for %d fonts from TTC file %r",
len(table_index), len(ttf_fonts), ttc_path)
return ttf_fonts, table_index, table_data


def _dump_ttf(base_name, ttf_fonts, table_index, table_data):
"""Write each TTF font to a separate font"""
created_paths = []

# Dump TTF fonts into separate files
for i, font in enumerate(ttf_fonts):
version, num_entries, triple, referenced_tables = font

def write(file, fmt, values):
raw = struct.pack(fmt, *values)
file.write(raw)

out_path = f"{base_name}{i}.ttf"
created_paths.append(out_path)
with open(out_path, "wb") as ttf_file:

write(ttf_file, ">HH", version)
write(ttf_file, ">H", (num_entries, ))
write(ttf_file, ">HHH", triple)

# Length of header and directory
file_offset = 12 + len(referenced_tables) * 16

# Write directory
for (offset, length) in referenced_tables:
tag, chksum, = table_index[(offset, length)]
write(ttf_file, ">IIII", (tag, chksum, file_offset, length))
file_offset += length

# Write tables
for table_coord in referenced_tables:
data = table_data[table_coord]
ttf_file.write(data)
_log.info("Created %r from TTC file", out_path)
return created_paths


def _load_fontmanager(*, try_read_cache=True):
fm_path = Path(
mpl.get_cachedir(), f"fontlist-v{FontManager.__version__}.json")
Expand Down
14 changes: 10 additions & 4 deletions lib/matplotlib/tests/test_font_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@
findfont, findSystemFonts, FontEntry, FontProperties, fontManager,
json_dump, json_load, get_font, is_opentype_cff_font,
MSUserFontDirectories, _get_fontconfig_fonts, ft2font,
ttfFontProperty, cbook)
from matplotlib import pyplot as plt, rc_context
ttfFontProperty, cbook, _load_fontmanager)
from matplotlib import pyplot as plt, rc_context, get_cachedir

has_fclist = shutil.which('fc-list') is not None

Expand Down Expand Up @@ -297,19 +297,25 @@ def test_fontentry_dataclass_invalid_path():

@pytest.mark.skipif(sys.platform == 'win32', reason='Linux or OS only')
def test_get_font_names():
# Ensure fonts like 'mpltest' are not in cache
new_fm = _load_fontmanager(try_read_cache=False)
mpl_font_names = sorted(new_fm.get_font_names())

paths_mpl = [cbook._get_data_path('fonts', subdir) for subdir in ['ttf']]
fonts_mpl = findSystemFonts(paths_mpl, fontext='ttf')
fonts_system = findSystemFonts(fontext='ttf')
# TTF extracted and cached from TTC
cached_fonts = findSystemFonts(get_cachedir(), fontext='ttf')
ttf_fonts = []
for path in fonts_mpl + fonts_system:
for path in fonts_mpl + fonts_system + cached_fonts:
try:
font = ft2font.FT2Font(path)
prop = ttfFontProperty(font)
ttf_fonts.append(prop.name)
except:
pass
available_fonts = sorted(list(set(ttf_fonts)))
mpl_font_names = sorted(fontManager.get_font_names())

assert set(available_fonts) == set(mpl_font_names)
assert len(available_fonts) == len(mpl_font_names)
assert available_fonts == mpl_font_names