diff --git a/doc/users/next_whats_new/ttc_font_support.rst b/doc/users/next_whats_new/ttc_font_support.rst new file mode 100644 index 000000000000..7c0cdf418852 --- /dev/null +++ b/doc/users/next_whats_new/ttc_font_support.rst @@ -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. diff --git a/lib/matplotlib/font_manager.py b/lib/matplotlib/font_manager.py index f57fc9c051b0..5fe1d10cb207 100644 --- a/lib/matplotlib/font_manager.py +++ b/lib/matplotlib/font_manager.py @@ -35,6 +35,7 @@ from pathlib import Path import re import subprocess +import struct import sys import threading @@ -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: @@ -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) @@ -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") diff --git a/lib/matplotlib/tests/test_font_manager.py b/lib/matplotlib/tests/test_font_manager.py index ef8b467f1709..a74a12ed7cbc 100644 --- a/lib/matplotlib/tests/test_font_manager.py +++ b/lib/matplotlib/tests/test_font_manager.py @@ -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 @@ -297,11 +297,17 @@ 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) @@ -309,7 +315,7 @@ def test_get_font_names(): 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