diff --git a/Lib/fontTools/ttLib/tables/_c_m_a_p.py b/Lib/fontTools/ttLib/tables/_c_m_a_p.py index c927791a0..a31b5059c 100644 --- a/Lib/fontTools/ttLib/tables/_c_m_a_p.py +++ b/Lib/fontTools/ttLib/tables/_c_m_a_p.py @@ -156,6 +156,12 @@ class table__c_m_a_p(DefaultTable.DefaultTable): else: seenOffsets[offset] = i tables.append(table) + if ttFont.lazy is False: # Be lazy for None and True + self.ensureDecompiled() + + def ensureDecompiled(self): + for st in self.tables: + st.ensureDecompiled() def compile(self, ttFont): self.tables.sort() # sort according to the spec; see CmapSubtable.__lt__() @@ -232,16 +238,21 @@ class CmapSubtable(object): self.platEncID = None #: The encoding ID of this subtable (interpretation depends on ``platformID``) self.language = None #: The language ID of this subtable (Macintosh platform only) + def ensureDecompiled(self): + if self.data is None: + return + self.decompile(None, None) # use saved data. + self.data = None # Once this table has been decompiled, make sure we don't + # just return the original data. Also avoids recursion when + # called with an attribute that the cmap subtable doesn't have. + def __getattr__(self, attr): # allow lazy decompilation of subtables. if attr[:2] == '__': # don't handle requests for member functions like '__lt__' raise AttributeError(attr) if self.data is None: raise AttributeError(attr) - self.decompile(None, None) # use saved data. - self.data = None # Once this table has been decompiled, make sure we don't - # just return the original data. Also avoids recursion when - # called with an attribute that the cmap subtable doesn't have. + self.ensureDecompiled() return getattr(self, attr) def decompileHeader(self, data, ttFont): diff --git a/Lib/fontTools/ttLib/tables/_g_l_y_f.py b/Lib/fontTools/ttLib/tables/_g_l_y_f.py index 9ec3a014a..14c4519db 100644 --- a/Lib/fontTools/ttLib/tables/_g_l_y_f.py +++ b/Lib/fontTools/ttLib/tables/_g_l_y_f.py @@ -110,8 +110,11 @@ class table__g_l_y_f(DefaultTable.DefaultTable): if noname: log.warning('%s glyphs have no name', noname) if ttFont.lazy is False: # Be lazy for None and True - for glyph in self.glyphs.values(): - glyph.expand(self) + self.ensureDecompiled() + + def ensureDecompiled(self): + for glyph in self.glyphs.values(): + glyph.expand(self) def compile(self, ttFont): if not hasattr(self, "glyphOrder"): diff --git a/Lib/fontTools/ttLib/tables/otBase.py b/Lib/fontTools/ttLib/tables/otBase.py index 5d379cb86..bc2c9fba8 100644 --- a/Lib/fontTools/ttLib/tables/otBase.py +++ b/Lib/fontTools/ttLib/tables/otBase.py @@ -4,6 +4,7 @@ import sys import array import struct import logging +from typing import Iterator, NamedTuple, Optional log = logging.getLogger(__name__) @@ -108,6 +109,9 @@ class BaseTTXConverter(DefaultTable): self.table.fromXML(name, attrs, content, font) self.table.populateDefaults() + def ensureDecompiled(self): + self.table.ensureDecompiled(recurse=True) + # https://github.com/fonttools/fonttools/pull/2285#issuecomment-834652928 assert len(struct.pack('i', 0)) == 4 @@ -596,13 +600,16 @@ class BaseTable(object): raise AttributeError(attr) - def ensureDecompiled(self): + def ensureDecompiled(self, recurse=False): reader = self.__dict__.get("reader") if reader: del self.reader font = self.font del self.font self.decompile(reader, font) + if recurse: + for subtable in self.iterSubTables(): + subtable.value.ensureDecompiled(recurse) @classmethod def getRecordSize(cls, reader): @@ -851,6 +858,37 @@ class BaseTable(object): return self.__dict__ == other.__dict__ + class SubTableEntry(NamedTuple): + """See BaseTable.iterSubTables()""" + name: str + value: "BaseTable" + index: Optional[int] = None # index into given array, None for single values + + def iterSubTables(self) -> Iterator[SubTableEntry]: + """Yield (name, value, index) namedtuples for all subtables of current table. + + A sub-table is an instance of BaseTable (or subclass thereof) that is a child + of self, the current parent table. + The tuples also contain the attribute name (str) of the of parent table to get + a subtable, and optionally, for lists of subtables (i.e. attributes associated + with a converter that has a 'repeat'), an index into the list containing the + given subtable value. + This method can be useful to traverse trees of otTables. + """ + for conv in self.getConverters(): + name = conv.name + value = getattr(self, name, None) + if value is None: + continue + if isinstance(value, BaseTable): + yield self.SubTableEntry(name, value) + elif isinstance(value, list): + yield from ( + self.SubTableEntry(name, v, index=i) + for i, v in enumerate(value) + if isinstance(v, BaseTable) + ) + class FormatSwitchingBaseTable(BaseTable): @@ -862,6 +900,15 @@ class FormatSwitchingBaseTable(BaseTable): return NotImplemented def getConverters(self): + try: + fmt = self.Format + except AttributeError: + # some FormatSwitchingBaseTables (e.g. Coverage) no longer have 'Format' + # attribute after fully decompiled, only gain one in preWrite before being + # recompiled. In the decompiled state, these hand-coded classes defined in + # otTables.py lose their format-specific nature and gain more high-level + # attributes that are not tied to converters. + return [] return self.converters.get(self.Format, []) def getConverterByName(self, name): diff --git a/Lib/fontTools/ttLib/ttFont.py b/Lib/fontTools/ttLib/ttFont.py index 692f99ac8..3929e2f3e 100644 --- a/Lib/fontTools/ttLib/ttFont.py +++ b/Lib/fontTools/ttLib/ttFont.py @@ -378,6 +378,14 @@ class TTFont(object): keys = sortedTagList(keys) return ["GlyphOrder"] + keys + def ensureDecompiled(self): + """Decompile all the tables, even if a TTFont was opened in 'lazy' mode.""" + for tag in self.keys(): + table = self[tag] + if self.lazy is not False and hasattr(table, "ensureDecompiled"): + table.ensureDecompiled() + self.lazy = False + def __len__(self): return len(list(self.keys())) diff --git a/Tests/ttLib/ttFont_test.py b/Tests/ttLib/ttFont_test.py index d40832728..e0e82b244 100644 --- a/Tests/ttLib/ttFont_test.py +++ b/Tests/ttLib/ttFont_test.py @@ -2,8 +2,12 @@ import io import os import re import random +from fontTools.feaLib.builder import addOpenTypeFeaturesFromString from fontTools.ttLib import TTFont, newTable, registerCustomTableClass, unregisterCustomTableClass from fontTools.ttLib.tables.DefaultTable import DefaultTable +from fontTools.ttLib.tables._c_m_a_p import CmapSubtable +import pytest + DATA_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), "data") @@ -137,3 +141,74 @@ def test_setGlyphOrder_also_updates_glyf_glyphOrder(): assert font.getGlyphOrder() == new_order assert font["glyf"].glyphOrder == new_order + + +@pytest.mark.parametrize("lazy", [None, True, False]) +def test_ensureDecompiled(lazy): + # test that no matter the lazy value, ensureDecompiled decompiles all tables + font = TTFont() + font.importXML(os.path.join(DATA_DIR, "TestTTF-Regular.ttx")) + # test font has no OTL so we add some, as an example of otData-driven tables + addOpenTypeFeaturesFromString( + font, + """ + feature calt { + sub period' period' period' space by ellipsis; + } calt; + + feature dist { + pos period period -30; + } dist; + """ + ) + # also add an additional cmap subtable that will be lazily-loaded + cm = CmapSubtable.newSubtable(14) + cm.platformID = 0 + cm.platEncID = 5 + cm.language = 0 + cm.cmap = {} + cm.uvsDict = {0xFE00: [(0x002e, None)]} + font["cmap"].tables.append(cm) + + # save and reload, potentially lazily + buf = io.BytesIO() + font.save(buf) + buf.seek(0) + font = TTFont(buf, lazy=lazy) + + # check no table is loaded until/unless requested, no matter the laziness + for tag in font.keys(): + assert not font.isLoaded(tag) + + if lazy is not False: + # additional cmap doesn't get decompiled automatically unless lazy=False; + # can't use hasattr or else cmap's maginc __getattr__ kicks in... + cm = next(st for st in font["cmap"].tables if st.__dict__["format"] == 14) + assert cm.data is not None + assert "uvsDict" not in cm.__dict__ + # glyf glyphs are not expanded unless lazy=False + assert font["glyf"].glyphs["period"].data is not None + assert not hasattr(font["glyf"].glyphs["period"], "coordinates") + + if lazy is True: + # OTL tables hold a 'reader' to lazily load when lazy=True + assert "reader" in font["GSUB"].table.LookupList.__dict__ + assert "reader" in font["GPOS"].table.LookupList.__dict__ + + font.ensureDecompiled() + + # all tables are decompiled now + for tag in font.keys(): + assert font.isLoaded(tag) + # including the additional cmap + cm = next(st for st in font["cmap"].tables if st.__dict__["format"] == 14) + assert cm.data is None + assert "uvsDict" in cm.__dict__ + # expanded glyf glyphs lost the 'data' attribute + assert not hasattr(font["glyf"].glyphs["period"], "data") + assert hasattr(font["glyf"].glyphs["period"], "coordinates") + # and OTL tables have read their 'reader' + assert "reader" not in font["GSUB"].table.LookupList.__dict__ + assert "Lookup" in font["GSUB"].table.LookupList.__dict__ + assert "reader" not in font["GPOS"].table.LookupList.__dict__ + assert "Lookup" in font["GPOS"].table.LookupList.__dict__