From 43c4d308b85a67254fdf73f60294aac29df2a08f Mon Sep 17 00:00:00 2001 From: Frank Sauerburger Date: Wed, 15 Jun 2022 16:14:39 +0200 Subject: [PATCH 1/5] Split and cache TTC font to TTF fonts --- lib/matplotlib/font_manager.py | 102 ++++++++++++++++++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/font_manager.py b/lib/matplotlib/font_manager.py index f57fc9c051b0..5377326eeb89 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,102 @@ def get_font(filename, hinting_factor=None): thread_id=threading.get_ident()) +def _split_ttc(ttc_path): + """SPlit a TTC ont 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) + + read(">HHI") # ttcf tag and version, ignore + 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) + + 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) + return created_paths + + def _load_fontmanager(*, try_read_cache=True): fm_path = Path( mpl.get_cachedir(), f"fontlist-v{FontManager.__version__}.json") From 59d9e629ed0d5bc315dca8349f73f6e32ef91a20 Mon Sep 17 00:00:00 2001 From: Frank Sauerburger Date: Mon, 20 Jun 2022 11:16:45 +0200 Subject: [PATCH 2/5] Add logging output for ttc font conversion --- lib/matplotlib/font_manager.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/font_manager.py b/lib/matplotlib/font_manager.py index 5377326eeb89..ebecc1f964e1 100644 --- a/lib/matplotlib/font_manager.py +++ b/lib/matplotlib/font_manager.py @@ -1502,7 +1502,15 @@ def read(fmt): data = ttc_file.read(size) return struct.unpack(fmt, data) - read(">HHI") # ttcf tag and version, ignore + 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 @@ -1534,6 +1542,8 @@ def read(fmt): 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 @@ -1570,6 +1580,7 @@ def write(file, fmt, values): 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 From 1220c51ef2da22be7091d705807476c6484d542f Mon Sep 17 00:00:00 2001 From: Frank Sauerburger Date: Mon, 20 Jun 2022 11:26:26 +0200 Subject: [PATCH 3/5] Add "What's new" user note for ttc font support --- doc/users/next_whats_new/ttc_font_support.rst | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 doc/users/next_whats_new/ttc_font_support.rst 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. From 2219c1adbfc82a5537608aba0cad978e2653deed Mon Sep 17 00:00:00 2001 From: Frank Sauerburger Date: Tue, 21 Jun 2022 17:08:20 +0200 Subject: [PATCH 4/5] Consider extracted TTF fonts in font_names test --- lib/matplotlib/tests/test_font_manager.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/lib/matplotlib/tests/test_font_manager.py b/lib/matplotlib/tests/test_font_manager.py index 254b9fdff38b..c3efac2f3352 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,6 +315,6 @@ def test_get_font_names(): except: pass available_fonts = sorted(list(set(ttf_fonts))) - mpl_font_names = sorted(fontManager.get_font_names()) + assert len(available_fonts) == len(mpl_font_names) assert available_fonts == mpl_font_names From 3cd9cf58ee9ad606df67147ff9ddaa701f682307 Mon Sep 17 00:00:00 2001 From: Frank Sauerburger Date: Mon, 4 Jul 2022 17:49:28 +0200 Subject: [PATCH 5/5] Update lib/matplotlib/font_manager.py Fix typos Co-authored-by: Oscar Gustafsson --- lib/matplotlib/font_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/matplotlib/font_manager.py b/lib/matplotlib/font_manager.py index ebecc1f964e1..5fe1d10cb207 100644 --- a/lib/matplotlib/font_manager.py +++ b/lib/matplotlib/font_manager.py @@ -1478,7 +1478,7 @@ def get_font(filename, hinting_factor=None): def _split_ttc(ttc_path): - """SPlit a TTC ont into TTF files""" + """Split a TTC file into TTF files""" res = _read_ttc(ttc_path) ttf_fonts, table_index, table_data = res out_base = Path(