| from fontTools.config import OPTIONS |
| from fontTools.misc.textTools import Tag, bytesjoin |
| from .DefaultTable import DefaultTable |
| from enum import IntEnum |
| import sys |
| import array |
| import struct |
| import logging |
| from functools import lru_cache |
| from typing import Iterator, NamedTuple, Optional, Tuple |
| |
| log = logging.getLogger(__name__) |
| |
| have_uharfbuzz = False |
| try: |
| import uharfbuzz as hb |
| # repack method added in uharfbuzz >= 0.23; if uharfbuzz *can* be |
| # imported but repack method is missing, behave as if uharfbuzz |
| # is not available (fallback to the slower Python implementation) |
| have_uharfbuzz = callable(getattr(hb, "repack", None)) |
| except ImportError: |
| pass |
| |
| USE_HARFBUZZ_REPACKER = OPTIONS[f"{__name__}:USE_HARFBUZZ_REPACKER"] |
| |
| class OverflowErrorRecord(object): |
| def __init__(self, overflowTuple): |
| self.tableType = overflowTuple[0] |
| self.LookupListIndex = overflowTuple[1] |
| self.SubTableIndex = overflowTuple[2] |
| self.itemName = overflowTuple[3] |
| self.itemIndex = overflowTuple[4] |
| |
| def __repr__(self): |
| return str((self.tableType, "LookupIndex:", self.LookupListIndex, "SubTableIndex:", self.SubTableIndex, "ItemName:", self.itemName, "ItemIndex:", self.itemIndex)) |
| |
| class OTLOffsetOverflowError(Exception): |
| def __init__(self, overflowErrorRecord): |
| self.value = overflowErrorRecord |
| |
| def __str__(self): |
| return repr(self.value) |
| |
| class RepackerState(IntEnum): |
| # Repacking control flow is implemnted using a state machine. The state machine table: |
| # |
| # State | Packing Success | Packing Failed | Exception Raised | |
| # ------------+-----------------+----------------+------------------+ |
| # PURE_FT | Return result | PURE_FT | Return failure | |
| # HB_FT | Return result | HB_FT | FT_FALLBACK | |
| # FT_FALLBACK | HB_FT | FT_FALLBACK | Return failure | |
| |
| # Pack only with fontTools, don't allow sharing between extensions. |
| PURE_FT = 1 |
| |
| # Attempt to pack with harfbuzz (allowing sharing between extensions) |
| # use fontTools to attempt overflow resolution. |
| HB_FT = 2 |
| |
| # Fallback if HB/FT packing gets stuck. Pack only with fontTools, don't allow sharing between |
| # extensions. |
| FT_FALLBACK = 3 |
| |
| class BaseTTXConverter(DefaultTable): |
| |
| """Generic base class for TTX table converters. It functions as an |
| adapter between the TTX (ttLib actually) table model and the model |
| we use for OpenType tables, which is necessarily subtly different. |
| """ |
| |
| def decompile(self, data, font): |
| """Create an object from the binary data. Called automatically on access.""" |
| from . import otTables |
| reader = OTTableReader(data, tableTag=self.tableTag) |
| tableClass = getattr(otTables, self.tableTag) |
| self.table = tableClass() |
| self.table.decompile(reader, font) |
| |
| def compile(self, font): |
| """Compiles the table into binary. Called automatically on save.""" |
| |
| # General outline: |
| # Create a top-level OTTableWriter for the GPOS/GSUB table. |
| # Call the compile method for the the table |
| # for each 'converter' record in the table converter list |
| # call converter's write method for each item in the value. |
| # - For simple items, the write method adds a string to the |
| # writer's self.items list. |
| # - For Struct/Table/Subtable items, it add first adds new writer to the |
| # to the writer's self.items, then calls the item's compile method. |
| # This creates a tree of writers, rooted at the GUSB/GPOS writer, with |
| # each writer representing a table, and the writer.items list containing |
| # the child data strings and writers. |
| # call the getAllData method |
| # call _doneWriting, which removes duplicates |
| # call _gatherTables. This traverses the tables, adding unique occurences to a flat list of tables |
| # Traverse the flat list of tables, calling getDataLength on each to update their position |
| # Traverse the flat list of tables again, calling getData each get the data in the table, now that |
| # pos's and offset are known. |
| |
| # If a lookup subtable overflows an offset, we have to start all over. |
| overflowRecord = None |
| # this is 3-state option: default (None) means automatically use hb.repack or |
| # silently fall back if it fails; True, use it and raise error if not possible |
| # or it errors out; False, don't use it, even if you can. |
| use_hb_repack = font.cfg[USE_HARFBUZZ_REPACKER] |
| if self.tableTag in ("GSUB", "GPOS"): |
| if use_hb_repack is False: |
| log.debug( |
| "hb.repack disabled, compiling '%s' with pure-python serializer", |
| self.tableTag, |
| ) |
| elif not have_uharfbuzz: |
| if use_hb_repack is True: |
| raise ImportError("No module named 'uharfbuzz'") |
| else: |
| assert use_hb_repack is None |
| log.debug( |
| "uharfbuzz not found, compiling '%s' with pure-python serializer", |
| self.tableTag, |
| ) |
| |
| if (use_hb_repack in (None, True) |
| and have_uharfbuzz |
| and self.tableTag in ("GSUB", "GPOS")): |
| state = RepackerState.HB_FT |
| else: |
| state = RepackerState.PURE_FT |
| |
| hb_first_error_logged = False |
| lastOverflowRecord = None |
| while True: |
| try: |
| writer = OTTableWriter(tableTag=self.tableTag) |
| self.table.compile(writer, font) |
| if state == RepackerState.HB_FT: |
| return self.tryPackingHarfbuzz(writer, hb_first_error_logged) |
| elif state == RepackerState.PURE_FT: |
| return self.tryPackingFontTools(writer) |
| elif state == RepackerState.FT_FALLBACK: |
| # Run packing with FontTools only, but don't return the result as it will |
| # not be optimally packed. Once a successful packing has been found, state is |
| # changed back to harfbuzz packing to produce the final, optimal, packing. |
| self.tryPackingFontTools(writer) |
| log.debug("Re-enabling sharing between extensions and switching back to " |
| "harfbuzz+fontTools packing.") |
| state = RepackerState.HB_FT |
| |
| except OTLOffsetOverflowError as e: |
| hb_first_error_logged = True |
| ok = self.tryResolveOverflow(font, e, lastOverflowRecord) |
| lastOverflowRecord = e.value |
| |
| if ok: |
| continue |
| |
| if state is RepackerState.HB_FT: |
| log.debug("Harfbuzz packing out of resolutions, disabling sharing between extensions and " |
| "switching to fontTools only packing.") |
| state = RepackerState.FT_FALLBACK |
| else: |
| raise |
| |
| def tryPackingHarfbuzz(self, writer, hb_first_error_logged): |
| try: |
| log.debug("serializing '%s' with hb.repack", self.tableTag) |
| return writer.getAllDataUsingHarfbuzz(self.tableTag) |
| except (ValueError, MemoryError, hb.RepackerError) as e: |
| # Only log hb repacker errors the first time they occur in |
| # the offset-overflow resolution loop, they are just noisy. |
| # Maybe we can revisit this if/when uharfbuzz actually gives |
| # us more info as to why hb.repack failed... |
| if not hb_first_error_logged: |
| error_msg = f"{type(e).__name__}" |
| if str(e) != "": |
| error_msg += f": {e}" |
| log.warning( |
| "hb.repack failed to serialize '%s', attempting fonttools resolutions " |
| "; the error message was: %s", |
| self.tableTag, |
| error_msg, |
| ) |
| hb_first_error_logged = True |
| return writer.getAllData(remove_duplicate=False) |
| |
| |
| def tryPackingFontTools(self, writer): |
| return writer.getAllData() |
| |
| |
| def tryResolveOverflow(self, font, e, lastOverflowRecord): |
| ok = 0 |
| if lastOverflowRecord == e.value: |
| # Oh well... |
| return ok |
| |
| overflowRecord = e.value |
| log.info("Attempting to fix OTLOffsetOverflowError %s", e) |
| |
| if overflowRecord.itemName is None: |
| from .otTables import fixLookupOverFlows |
| ok = fixLookupOverFlows(font, overflowRecord) |
| else: |
| from .otTables import fixSubTableOverFlows |
| ok = fixSubTableOverFlows(font, overflowRecord) |
| |
| if ok: |
| return ok |
| |
| # Try upgrading lookup to Extension and hope |
| # that cross-lookup sharing not happening would |
| # fix overflow... |
| from .otTables import fixLookupOverFlows |
| return fixLookupOverFlows(font, overflowRecord) |
| |
| def toXML(self, writer, font): |
| self.table.toXML2(writer, font) |
| |
| def fromXML(self, name, attrs, content, font): |
| from . import otTables |
| if not hasattr(self, "table"): |
| tableClass = getattr(otTables, self.tableTag) |
| self.table = tableClass() |
| self.table.fromXML(name, attrs, content, font) |
| self.table.populateDefaults() |
| |
| def ensureDecompiled(self, recurse=True): |
| self.table.ensureDecompiled(recurse=recurse) |
| |
| |
| # https://github.com/fonttools/fonttools/pull/2285#issuecomment-834652928 |
| assert len(struct.pack('i', 0)) == 4 |
| assert array.array('i').itemsize == 4, "Oops, file a bug against fonttools." |
| |
| class OTTableReader(object): |
| |
| """Helper class to retrieve data from an OpenType table.""" |
| |
| __slots__ = ('data', 'offset', 'pos', 'localState', 'tableTag') |
| |
| def __init__(self, data, localState=None, offset=0, tableTag=None): |
| self.data = data |
| self.offset = offset |
| self.pos = offset |
| self.localState = localState |
| self.tableTag = tableTag |
| |
| def advance(self, count): |
| self.pos += count |
| |
| def seek(self, pos): |
| self.pos = pos |
| |
| def copy(self): |
| other = self.__class__(self.data, self.localState, self.offset, self.tableTag) |
| other.pos = self.pos |
| return other |
| |
| def getSubReader(self, offset): |
| offset = self.offset + offset |
| return self.__class__(self.data, self.localState, offset, self.tableTag) |
| |
| def readValue(self, typecode, staticSize): |
| pos = self.pos |
| newpos = pos + staticSize |
| value, = struct.unpack(f">{typecode}", self.data[pos:newpos]) |
| self.pos = newpos |
| return value |
| def readArray(self, typecode, staticSize, count): |
| pos = self.pos |
| newpos = pos + count * staticSize |
| value = array.array(typecode, self.data[pos:newpos]) |
| if sys.byteorder != "big": value.byteswap() |
| self.pos = newpos |
| return value.tolist() |
| |
| def readInt8(self): |
| return self.readValue("b", staticSize=1) |
| def readInt8Array(self, count): |
| return self.readArray("b", staticSize=1, count=count) |
| |
| def readShort(self): |
| return self.readValue("h", staticSize=2) |
| def readShortArray(self, count): |
| return self.readArray("h", staticSize=2, count=count) |
| |
| def readLong(self): |
| return self.readValue("i", staticSize=4) |
| def readLongArray(self, count): |
| return self.readArray("i", staticSize=4, count=count) |
| |
| def readUInt8(self): |
| return self.readValue("B", staticSize=1) |
| def readUInt8Array(self, count): |
| return self.readArray("B", staticSize=1, count=count) |
| |
| def readUShort(self): |
| return self.readValue("H", staticSize=2) |
| def readUShortArray(self, count): |
| return self.readArray("H", staticSize=2, count=count) |
| |
| def readULong(self): |
| return self.readValue("I", staticSize=4) |
| def readULongArray(self, count): |
| return self.readArray("I", staticSize=4, count=count) |
| |
| def readUInt24(self): |
| pos = self.pos |
| newpos = pos + 3 |
| value, = struct.unpack(">l", b'\0'+self.data[pos:newpos]) |
| self.pos = newpos |
| return value |
| def readUInt24Array(self, count): |
| return [self.readUInt24() for _ in range(count)] |
| |
| def readTag(self): |
| pos = self.pos |
| newpos = pos + 4 |
| value = Tag(self.data[pos:newpos]) |
| assert len(value) == 4, value |
| self.pos = newpos |
| return value |
| |
| def readData(self, count): |
| pos = self.pos |
| newpos = pos + count |
| value = self.data[pos:newpos] |
| self.pos = newpos |
| return value |
| |
| def __setitem__(self, name, value): |
| state = self.localState.copy() if self.localState else dict() |
| state[name] = value |
| self.localState = state |
| |
| def __getitem__(self, name): |
| return self.localState and self.localState[name] |
| |
| def __contains__(self, name): |
| return self.localState and name in self.localState |
| |
| |
| class OTTableWriter(object): |
| |
| """Helper class to gather and assemble data for OpenType tables.""" |
| |
| def __init__(self, localState=None, tableTag=None, offsetSize=2): |
| self.items = [] |
| self.pos = None |
| self.localState = localState |
| self.tableTag = tableTag |
| self.offsetSize = offsetSize |
| self.parent = None |
| |
| # DEPRECATED: 'longOffset' is kept as a property for backward compat with old code. |
| # You should use 'offsetSize' instead (2, 3 or 4 bytes). |
| @property |
| def longOffset(self): |
| return self.offsetSize == 4 |
| |
| @longOffset.setter |
| def longOffset(self, value): |
| self.offsetSize = 4 if value else 2 |
| |
| def __setitem__(self, name, value): |
| state = self.localState.copy() if self.localState else dict() |
| state[name] = value |
| self.localState = state |
| |
| def __getitem__(self, name): |
| return self.localState[name] |
| |
| def __delitem__(self, name): |
| del self.localState[name] |
| |
| # assembler interface |
| |
| def getDataLength(self): |
| """Return the length of this table in bytes, without subtables.""" |
| l = 0 |
| for item in self.items: |
| if hasattr(item, "getCountData"): |
| l += item.size |
| elif hasattr(item, "getData"): |
| l += item.offsetSize |
| else: |
| l = l + len(item) |
| return l |
| |
| def getData(self): |
| """Assemble the data for this writer/table, without subtables.""" |
| items = list(self.items) # make a shallow copy |
| pos = self.pos |
| numItems = len(items) |
| for i in range(numItems): |
| item = items[i] |
| |
| if hasattr(item, "getData"): |
| if item.offsetSize == 4: |
| items[i] = packULong(item.pos - pos) |
| elif item.offsetSize == 2: |
| try: |
| items[i] = packUShort(item.pos - pos) |
| except struct.error: |
| # provide data to fix overflow problem. |
| overflowErrorRecord = self.getOverflowErrorRecord(item) |
| |
| raise OTLOffsetOverflowError(overflowErrorRecord) |
| elif item.offsetSize == 3: |
| items[i] = packUInt24(item.pos - pos) |
| else: |
| raise ValueError(item.offsetSize) |
| |
| return bytesjoin(items) |
| |
| def getDataForHarfbuzz(self): |
| """Assemble the data for this writer/table with all offset field set to 0""" |
| items = list(self.items) |
| packFuncs = {2: packUShort, 3: packUInt24, 4: packULong} |
| for i, item in enumerate(items): |
| if hasattr(item, "getData"): |
| # Offset value is not needed in harfbuzz repacker, so setting offset to 0 to avoid overflow here |
| if item.offsetSize in packFuncs: |
| items[i] = packFuncs[item.offsetSize](0) |
| else: |
| raise ValueError(item.offsetSize) |
| |
| return bytesjoin(items) |
| |
| def __hash__(self): |
| # only works after self._doneWriting() has been called |
| return hash(self.items) |
| |
| def __ne__(self, other): |
| result = self.__eq__(other) |
| return result if result is NotImplemented else not result |
| |
| def __eq__(self, other): |
| if type(self) != type(other): |
| return NotImplemented |
| return self.offsetSize == other.offsetSize and self.items == other.items |
| |
| def _doneWriting(self, internedTables, shareExtension=False): |
| # Convert CountData references to data string items |
| # collapse duplicate table references to a unique entry |
| # "tables" are OTTableWriter objects. |
| |
| # For Extension Lookup types, we can |
| # eliminate duplicates only within the tree under the Extension Lookup, |
| # as offsets may exceed 64K even between Extension LookupTable subtables. |
| isExtension = hasattr(self, "Extension") |
| |
| # Certain versions of Uniscribe reject the font if the GSUB/GPOS top-level |
| # arrays (ScriptList, FeatureList, LookupList) point to the same, possibly |
| # empty, array. So, we don't share those. |
| # See: https://github.com/fonttools/fonttools/issues/518 |
| dontShare = hasattr(self, 'DontShare') |
| |
| if isExtension and not shareExtension: |
| internedTables = {} |
| |
| items = self.items |
| for i in range(len(items)): |
| item = items[i] |
| if hasattr(item, "getCountData"): |
| items[i] = item.getCountData() |
| elif hasattr(item, "getData"): |
| item._doneWriting(internedTables, shareExtension=shareExtension) |
| # At this point, all subwriters are hashable based on their items. |
| # (See hash and comparison magic methods above.) So the ``setdefault`` |
| # call here will return the first writer object we've seen with |
| # equal content, or store it in the dictionary if it's not been |
| # seen yet. We therefore replace the subwriter object with an equivalent |
| # object, which deduplicates the tree. |
| if not dontShare: |
| items[i] = item = internedTables.setdefault(item, item) |
| self.items = tuple(items) |
| |
| def _gatherTables(self, tables, extTables, done): |
| # Convert table references in self.items tree to a flat |
| # list of tables in depth-first traversal order. |
| # "tables" are OTTableWriter objects. |
| # We do the traversal in reverse order at each level, in order to |
| # resolve duplicate references to be the last reference in the list of tables. |
| # For extension lookups, duplicate references can be merged only within the |
| # writer tree under the extension lookup. |
| |
| done[id(self)] = True |
| |
| numItems = len(self.items) |
| iRange = list(range(numItems)) |
| iRange.reverse() |
| |
| isExtension = hasattr(self, "Extension") |
| |
| selfTables = tables |
| |
| if isExtension: |
| assert extTables is not None, "Program or XML editing error. Extension subtables cannot contain extensions subtables" |
| tables, extTables, done = extTables, None, {} |
| |
| # add Coverage table if it is sorted last. |
| sortCoverageLast = False |
| if hasattr(self, "sortCoverageLast"): |
| # Find coverage table |
| for i in range(numItems): |
| item = self.items[i] |
| if getattr(item, 'name', None) == "Coverage": |
| sortCoverageLast = True |
| break |
| if id(item) not in done: |
| item._gatherTables(tables, extTables, done) |
| else: |
| # We're a new parent of item |
| pass |
| |
| for i in iRange: |
| item = self.items[i] |
| if not hasattr(item, "getData"): |
| continue |
| |
| if sortCoverageLast and (i==1) and getattr(item, 'name', None) == 'Coverage': |
| # we've already 'gathered' it above |
| continue |
| |
| if id(item) not in done: |
| item._gatherTables(tables, extTables, done) |
| else: |
| # Item is already written out by other parent |
| pass |
| |
| selfTables.append(self) |
| |
| def _gatherGraphForHarfbuzz(self, tables, obj_list, done, objidx, virtual_edges): |
| real_links = [] |
| virtual_links = [] |
| item_idx = objidx |
| |
| # Merge virtual_links from parent |
| for idx in virtual_edges: |
| virtual_links.append((0, 0, idx)) |
| |
| sortCoverageLast = False |
| coverage_idx = 0 |
| if hasattr(self, "sortCoverageLast"): |
| # Find coverage table |
| for i, item in enumerate(self.items): |
| if getattr(item, 'name', None) == "Coverage": |
| sortCoverageLast = True |
| if id(item) not in done: |
| coverage_idx = item_idx = item._gatherGraphForHarfbuzz(tables, obj_list, done, item_idx, virtual_edges) |
| else: |
| coverage_idx = done[id(item)] |
| virtual_edges.append(coverage_idx) |
| break |
| |
| child_idx = 0 |
| offset_pos = 0 |
| for i, item in enumerate(self.items): |
| if hasattr(item, "getData"): |
| pos = offset_pos |
| elif hasattr(item, "getCountData"): |
| offset_pos += item.size |
| continue |
| else: |
| offset_pos = offset_pos + len(item) |
| continue |
| |
| if id(item) not in done: |
| child_idx = item_idx = item._gatherGraphForHarfbuzz(tables, obj_list, done, item_idx, virtual_edges) |
| else: |
| child_idx = done[id(item)] |
| |
| real_edge = (pos, item.offsetSize, child_idx) |
| real_links.append(real_edge) |
| offset_pos += item.offsetSize |
| |
| tables.append(self) |
| obj_list.append((real_links,virtual_links)) |
| item_idx += 1 |
| done[id(self)] = item_idx |
| if sortCoverageLast: |
| virtual_edges.pop() |
| |
| return item_idx |
| |
| def getAllDataUsingHarfbuzz(self, tableTag): |
| """The Whole table is represented as a Graph. |
| Assemble graph data and call Harfbuzz repacker to pack the table. |
| Harfbuzz repacker is faster and retain as much sub-table sharing as possible, see also: |
| https://github.com/harfbuzz/harfbuzz/blob/main/docs/repacker.md |
| The input format for hb.repack() method is explained here: |
| https://github.com/harfbuzz/uharfbuzz/blob/main/src/uharfbuzz/_harfbuzz.pyx#L1149 |
| """ |
| internedTables = {} |
| self._doneWriting(internedTables, shareExtension=True) |
| tables = [] |
| obj_list = [] |
| done = {} |
| objidx = 0 |
| virtual_edges = [] |
| self._gatherGraphForHarfbuzz(tables, obj_list, done, objidx, virtual_edges) |
| # Gather all data in two passes: the absolute positions of all |
| # subtable are needed before the actual data can be assembled. |
| pos = 0 |
| for table in tables: |
| table.pos = pos |
| pos = pos + table.getDataLength() |
| |
| data = [] |
| for table in tables: |
| tableData = table.getDataForHarfbuzz() |
| data.append(tableData) |
| |
| if hasattr(hb, "repack_with_tag"): |
| return hb.repack_with_tag(str(tableTag), data, obj_list) |
| else: |
| return hb.repack(data, obj_list) |
| |
| def getAllData(self, remove_duplicate=True): |
| """Assemble all data, including all subtables.""" |
| if remove_duplicate: |
| internedTables = {} |
| self._doneWriting(internedTables) |
| tables = [] |
| extTables = [] |
| done = {} |
| self._gatherTables(tables, extTables, done) |
| tables.reverse() |
| extTables.reverse() |
| # Gather all data in two passes: the absolute positions of all |
| # subtable are needed before the actual data can be assembled. |
| pos = 0 |
| for table in tables: |
| table.pos = pos |
| pos = pos + table.getDataLength() |
| |
| for table in extTables: |
| table.pos = pos |
| pos = pos + table.getDataLength() |
| |
| data = [] |
| for table in tables: |
| tableData = table.getData() |
| data.append(tableData) |
| |
| for table in extTables: |
| tableData = table.getData() |
| data.append(tableData) |
| |
| return bytesjoin(data) |
| |
| # interface for gathering data, as used by table.compile() |
| |
| def getSubWriter(self, offsetSize=2): |
| subwriter = self.__class__(self.localState, self.tableTag, offsetSize=offsetSize) |
| subwriter.parent = self # because some subtables have idential values, we discard |
| # the duplicates under the getAllData method. Hence some |
| # subtable writers can have more than one parent writer. |
| # But we just care about first one right now. |
| return subwriter |
| |
| def writeValue(self, typecode, value): |
| self.items.append(struct.pack(f">{typecode}", value)) |
| def writeArray(self, typecode, values): |
| a = array.array(typecode, values) |
| if sys.byteorder != "big": a.byteswap() |
| self.items.append(a.tobytes()) |
| |
| def writeInt8(self, value): |
| assert -128 <= value < 128, value |
| self.items.append(struct.pack(">b", value)) |
| def writeInt8Array(self, values): |
| self.writeArray('b', values) |
| |
| def writeShort(self, value): |
| assert -32768 <= value < 32768, value |
| self.items.append(struct.pack(">h", value)) |
| def writeShortArray(self, values): |
| self.writeArray('h', values) |
| |
| def writeLong(self, value): |
| self.items.append(struct.pack(">i", value)) |
| def writeLongArray(self, values): |
| self.writeArray('i', values) |
| |
| def writeUInt8(self, value): |
| assert 0 <= value < 256, value |
| self.items.append(struct.pack(">B", value)) |
| def writeUInt8Array(self, values): |
| self.writeArray('B', values) |
| |
| def writeUShort(self, value): |
| assert 0 <= value < 0x10000, value |
| self.items.append(struct.pack(">H", value)) |
| def writeUShortArray(self, values): |
| self.writeArray('H', values) |
| |
| def writeULong(self, value): |
| self.items.append(struct.pack(">I", value)) |
| def writeULongArray(self, values): |
| self.writeArray('I', values) |
| |
| def writeUInt24(self, value): |
| assert 0 <= value < 0x1000000, value |
| b = struct.pack(">L", value) |
| self.items.append(b[1:]) |
| def writeUInt24Array(self, values): |
| for value in values: |
| self.writeUInt24(value) |
| |
| def writeTag(self, tag): |
| tag = Tag(tag).tobytes() |
| assert len(tag) == 4, tag |
| self.items.append(tag) |
| |
| def writeSubTable(self, subWriter): |
| self.items.append(subWriter) |
| |
| def writeCountReference(self, table, name, size=2, value=None): |
| ref = CountReference(table, name, size=size, value=value) |
| self.items.append(ref) |
| return ref |
| |
| def writeStruct(self, format, values): |
| data = struct.pack(*(format,) + values) |
| self.items.append(data) |
| |
| def writeData(self, data): |
| self.items.append(data) |
| |
| def getOverflowErrorRecord(self, item): |
| LookupListIndex = SubTableIndex = itemName = itemIndex = None |
| if self.name == 'LookupList': |
| LookupListIndex = item.repeatIndex |
| elif self.name == 'Lookup': |
| LookupListIndex = self.repeatIndex |
| SubTableIndex = item.repeatIndex |
| else: |
| itemName = getattr(item, 'name', '<none>') |
| if hasattr(item, 'repeatIndex'): |
| itemIndex = item.repeatIndex |
| if self.name == 'SubTable': |
| LookupListIndex = self.parent.repeatIndex |
| SubTableIndex = self.repeatIndex |
| elif self.name == 'ExtSubTable': |
| LookupListIndex = self.parent.parent.repeatIndex |
| SubTableIndex = self.parent.repeatIndex |
| else: # who knows how far below the SubTable level we are! Climb back up to the nearest subtable. |
| itemName = ".".join([self.name, itemName]) |
| p1 = self.parent |
| while p1 and p1.name not in ['ExtSubTable', 'SubTable']: |
| itemName = ".".join([p1.name, itemName]) |
| p1 = p1.parent |
| if p1: |
| if p1.name == 'ExtSubTable': |
| LookupListIndex = p1.parent.parent.repeatIndex |
| SubTableIndex = p1.parent.repeatIndex |
| else: |
| LookupListIndex = p1.parent.repeatIndex |
| SubTableIndex = p1.repeatIndex |
| |
| return OverflowErrorRecord( (self.tableTag, LookupListIndex, SubTableIndex, itemName, itemIndex) ) |
| |
| |
| class CountReference(object): |
| """A reference to a Count value, not a count of references.""" |
| def __init__(self, table, name, size=None, value=None): |
| self.table = table |
| self.name = name |
| self.size = size |
| if value is not None: |
| self.setValue(value) |
| def setValue(self, value): |
| table = self.table |
| name = self.name |
| if table[name] is None: |
| table[name] = value |
| else: |
| assert table[name] == value, (name, table[name], value) |
| def getValue(self): |
| return self.table[self.name] |
| def getCountData(self): |
| v = self.table[self.name] |
| if v is None: v = 0 |
| return {1:packUInt8, 2:packUShort, 4:packULong}[self.size](v) |
| |
| |
| def packUInt8 (value): |
| return struct.pack(">B", value) |
| |
| def packUShort(value): |
| return struct.pack(">H", value) |
| |
| def packULong(value): |
| assert 0 <= value < 0x100000000, value |
| return struct.pack(">I", value) |
| |
| def packUInt24(value): |
| assert 0 <= value < 0x1000000, value |
| return struct.pack(">I", value)[1:] |
| |
| |
| class BaseTable(object): |
| |
| """Generic base class for all OpenType (sub)tables.""" |
| |
| def __getattr__(self, attr): |
| reader = self.__dict__.get("reader") |
| if reader: |
| del self.reader |
| font = self.font |
| del self.font |
| self.decompile(reader, font) |
| return getattr(self, attr) |
| |
| raise AttributeError(attr) |
| |
| 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): |
| totalSize = 0 |
| for conv in cls.converters: |
| size = conv.getRecordSize(reader) |
| if size is NotImplemented: return NotImplemented |
| countValue = 1 |
| if conv.repeat: |
| if conv.repeat in reader: |
| countValue = reader[conv.repeat] + conv.aux |
| else: |
| return NotImplemented |
| totalSize += size * countValue |
| return totalSize |
| |
| def getConverters(self): |
| return self.converters |
| |
| def getConverterByName(self, name): |
| return self.convertersByName[name] |
| |
| def populateDefaults(self, propagator=None): |
| for conv in self.getConverters(): |
| if conv.repeat: |
| if not hasattr(self, conv.name): |
| setattr(self, conv.name, []) |
| countValue = len(getattr(self, conv.name)) - conv.aux |
| try: |
| count_conv = self.getConverterByName(conv.repeat) |
| setattr(self, conv.repeat, countValue) |
| except KeyError: |
| # conv.repeat is a propagated count |
| if propagator and conv.repeat in propagator: |
| propagator[conv.repeat].setValue(countValue) |
| else: |
| if conv.aux and not eval(conv.aux, None, self.__dict__): |
| continue |
| if hasattr(self, conv.name): |
| continue # Warn if it should NOT be present?! |
| if hasattr(conv, 'writeNullOffset'): |
| setattr(self, conv.name, None) # Warn? |
| #elif not conv.isCount: |
| # # Warn? |
| # pass |
| if hasattr(conv, "DEFAULT"): |
| # OptionalValue converters (e.g. VarIndex) |
| setattr(self, conv.name, conv.DEFAULT) |
| |
| def decompile(self, reader, font): |
| self.readFormat(reader) |
| table = {} |
| self.__rawTable = table # for debugging |
| for conv in self.getConverters(): |
| if conv.name == "SubTable": |
| conv = conv.getConverter(reader.tableTag, |
| table["LookupType"]) |
| if conv.name == "ExtSubTable": |
| conv = conv.getConverter(reader.tableTag, |
| table["ExtensionLookupType"]) |
| if conv.name == "FeatureParams": |
| conv = conv.getConverter(reader["FeatureTag"]) |
| if conv.name == "SubStruct": |
| conv = conv.getConverter(reader.tableTag, |
| table["MorphType"]) |
| try: |
| if conv.repeat: |
| if isinstance(conv.repeat, int): |
| countValue = conv.repeat |
| elif conv.repeat in table: |
| countValue = table[conv.repeat] |
| else: |
| # conv.repeat is a propagated count |
| countValue = reader[conv.repeat] |
| countValue += conv.aux |
| table[conv.name] = conv.readArray(reader, font, table, countValue) |
| else: |
| if conv.aux and not eval(conv.aux, None, table): |
| continue |
| table[conv.name] = conv.read(reader, font, table) |
| if conv.isPropagated: |
| reader[conv.name] = table[conv.name] |
| except Exception as e: |
| name = conv.name |
| e.args = e.args + (name,) |
| raise |
| |
| if hasattr(self, 'postRead'): |
| self.postRead(table, font) |
| else: |
| self.__dict__.update(table) |
| |
| del self.__rawTable # succeeded, get rid of debugging info |
| |
| def compile(self, writer, font): |
| self.ensureDecompiled() |
| # TODO Following hack to be removed by rewriting how FormatSwitching tables |
| # are handled. |
| # https://github.com/fonttools/fonttools/pull/2238#issuecomment-805192631 |
| if hasattr(self, 'preWrite'): |
| deleteFormat = not hasattr(self, 'Format') |
| table = self.preWrite(font) |
| deleteFormat = deleteFormat and hasattr(self, 'Format') |
| else: |
| deleteFormat = False |
| table = self.__dict__.copy() |
| |
| # some count references may have been initialized in a custom preWrite; we set |
| # these in the writer's state beforehand (instead of sequentially) so they will |
| # be propagated to all nested subtables even if the count appears in the current |
| # table only *after* the offset to the subtable that it is counting. |
| for conv in self.getConverters(): |
| if conv.isCount and conv.isPropagated: |
| value = table.get(conv.name) |
| if isinstance(value, CountReference): |
| writer[conv.name] = value |
| |
| if hasattr(self, 'sortCoverageLast'): |
| writer.sortCoverageLast = 1 |
| |
| if hasattr(self, 'DontShare'): |
| writer.DontShare = True |
| |
| if hasattr(self.__class__, 'LookupType'): |
| writer['LookupType'].setValue(self.__class__.LookupType) |
| |
| self.writeFormat(writer) |
| for conv in self.getConverters(): |
| value = table.get(conv.name) # TODO Handle defaults instead of defaulting to None! |
| if conv.repeat: |
| if value is None: |
| value = [] |
| countValue = len(value) - conv.aux |
| if isinstance(conv.repeat, int): |
| assert len(value) == conv.repeat, 'expected %d values, got %d' % (conv.repeat, len(value)) |
| elif conv.repeat in table: |
| CountReference(table, conv.repeat, value=countValue) |
| else: |
| # conv.repeat is a propagated count |
| writer[conv.repeat].setValue(countValue) |
| try: |
| conv.writeArray(writer, font, table, value) |
| except Exception as e: |
| e.args = e.args + (conv.name+'[]',) |
| raise |
| elif conv.isCount: |
| # Special-case Count values. |
| # Assumption: a Count field will *always* precede |
| # the actual array(s). |
| # We need a default value, as it may be set later by a nested |
| # table. We will later store it here. |
| # We add a reference: by the time the data is assembled |
| # the Count value will be filled in. |
| # We ignore the current count value since it will be recomputed, |
| # unless it's a CountReference that was already initialized in a custom preWrite. |
| if isinstance(value, CountReference): |
| ref = value |
| ref.size = conv.staticSize |
| writer.writeData(ref) |
| table[conv.name] = ref.getValue() |
| else: |
| ref = writer.writeCountReference(table, conv.name, conv.staticSize) |
| table[conv.name] = None |
| if conv.isPropagated: |
| writer[conv.name] = ref |
| elif conv.isLookupType: |
| # We make sure that subtables have the same lookup type, |
| # and that the type is the same as the one set on the |
| # Lookup object, if any is set. |
| if conv.name not in table: |
| table[conv.name] = None |
| ref = writer.writeCountReference(table, conv.name, conv.staticSize, table[conv.name]) |
| writer['LookupType'] = ref |
| else: |
| if conv.aux and not eval(conv.aux, None, table): |
| continue |
| try: |
| conv.write(writer, font, table, value) |
| except Exception as e: |
| name = value.__class__.__name__ if value is not None else conv.name |
| e.args = e.args + (name,) |
| raise |
| if conv.isPropagated: |
| writer[conv.name] = value |
| |
| if deleteFormat: |
| del self.Format |
| |
| def readFormat(self, reader): |
| pass |
| |
| def writeFormat(self, writer): |
| pass |
| |
| def toXML(self, xmlWriter, font, attrs=None, name=None): |
| tableName = name if name else self.__class__.__name__ |
| if attrs is None: |
| attrs = [] |
| if hasattr(self, "Format"): |
| attrs = attrs + [("Format", self.Format)] |
| xmlWriter.begintag(tableName, attrs) |
| xmlWriter.newline() |
| self.toXML2(xmlWriter, font) |
| xmlWriter.endtag(tableName) |
| xmlWriter.newline() |
| |
| def toXML2(self, xmlWriter, font): |
| # Simpler variant of toXML, *only* for the top level tables (like GPOS, GSUB). |
| # This is because in TTX our parent writes our main tag, and in otBase.py we |
| # do it ourselves. I think I'm getting schizophrenic... |
| for conv in self.getConverters(): |
| if conv.repeat: |
| value = getattr(self, conv.name, []) |
| for i in range(len(value)): |
| item = value[i] |
| conv.xmlWrite(xmlWriter, font, item, conv.name, |
| [("index", i)]) |
| else: |
| if conv.aux and not eval(conv.aux, None, vars(self)): |
| continue |
| value = getattr(self, conv.name, None) # TODO Handle defaults instead of defaulting to None! |
| conv.xmlWrite(xmlWriter, font, value, conv.name, []) |
| |
| def fromXML(self, name, attrs, content, font): |
| try: |
| conv = self.getConverterByName(name) |
| except KeyError: |
| raise # XXX on KeyError, raise nice error |
| value = conv.xmlRead(attrs, content, font) |
| if conv.repeat: |
| seq = getattr(self, conv.name, None) |
| if seq is None: |
| seq = [] |
| setattr(self, conv.name, seq) |
| seq.append(value) |
| else: |
| setattr(self, conv.name, value) |
| |
| def __ne__(self, other): |
| result = self.__eq__(other) |
| return result if result is NotImplemented else not result |
| |
| def __eq__(self, other): |
| if type(self) != type(other): |
| return NotImplemented |
| |
| self.ensureDecompiled() |
| other.ensureDecompiled() |
| |
| 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) |
| ) |
| |
| # instance (not @class)method for consistency with FormatSwitchingBaseTable |
| def getVariableAttrs(self): |
| return getVariableAttrs(self.__class__) |
| |
| |
| class FormatSwitchingBaseTable(BaseTable): |
| |
| """Minor specialization of BaseTable, for tables that have multiple |
| formats, eg. CoverageFormat1 vs. CoverageFormat2.""" |
| |
| @classmethod |
| def getRecordSize(cls, reader): |
| 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): |
| return self.convertersByName[self.Format][name] |
| |
| def readFormat(self, reader): |
| self.Format = reader.readUShort() |
| |
| def writeFormat(self, writer): |
| writer.writeUShort(self.Format) |
| |
| def toXML(self, xmlWriter, font, attrs=None, name=None): |
| BaseTable.toXML(self, xmlWriter, font, attrs, name) |
| |
| def getVariableAttrs(self): |
| return getVariableAttrs(self.__class__, self.Format) |
| |
| |
| class UInt8FormatSwitchingBaseTable(FormatSwitchingBaseTable): |
| def readFormat(self, reader): |
| self.Format = reader.readUInt8() |
| |
| def writeFormat(self, writer): |
| writer.writeUInt8(self.Format) |
| |
| |
| formatSwitchingBaseTables = { |
| "uint16": FormatSwitchingBaseTable, |
| "uint8": UInt8FormatSwitchingBaseTable, |
| } |
| |
| def getFormatSwitchingBaseTableClass(formatType): |
| try: |
| return formatSwitchingBaseTables[formatType] |
| except KeyError: |
| raise TypeError(f"Unsupported format type: {formatType!r}") |
| |
| |
| # memoize since these are parsed from otData.py, thus stay constant |
| @lru_cache() |
| def getVariableAttrs(cls: BaseTable, fmt: Optional[int] = None) -> Tuple[str]: |
| """Return sequence of variable table field names (can be empty). |
| |
| Attributes are deemed "variable" when their otData.py's description contain |
| 'VarIndexBase + {offset}', e.g. COLRv1 PaintVar* tables. |
| """ |
| if not issubclass(cls, BaseTable): |
| raise TypeError(cls) |
| if issubclass(cls, FormatSwitchingBaseTable): |
| if fmt is None: |
| raise TypeError(f"'fmt' is required for format-switching {cls.__name__}") |
| converters = cls.convertersByName[fmt] |
| else: |
| converters = cls.convertersByName |
| # assume if no 'VarIndexBase' field is present, table has no variable fields |
| if "VarIndexBase" not in converters: |
| return () |
| varAttrs = {} |
| for name, conv in converters.items(): |
| offset = conv.getVarIndexOffset() |
| if offset is not None: |
| varAttrs[name] = offset |
| return tuple(sorted(varAttrs, key=varAttrs.__getitem__)) |
| |
| |
| # |
| # Support for ValueRecords |
| # |
| # This data type is so different from all other OpenType data types that |
| # it requires quite a bit of code for itself. It even has special support |
| # in OTTableReader and OTTableWriter... |
| # |
| |
| valueRecordFormat = [ |
| # Mask Name isDevice signed |
| (0x0001, "XPlacement", 0, 1), |
| (0x0002, "YPlacement", 0, 1), |
| (0x0004, "XAdvance", 0, 1), |
| (0x0008, "YAdvance", 0, 1), |
| (0x0010, "XPlaDevice", 1, 0), |
| (0x0020, "YPlaDevice", 1, 0), |
| (0x0040, "XAdvDevice", 1, 0), |
| (0x0080, "YAdvDevice", 1, 0), |
| # reserved: |
| (0x0100, "Reserved1", 0, 0), |
| (0x0200, "Reserved2", 0, 0), |
| (0x0400, "Reserved3", 0, 0), |
| (0x0800, "Reserved4", 0, 0), |
| (0x1000, "Reserved5", 0, 0), |
| (0x2000, "Reserved6", 0, 0), |
| (0x4000, "Reserved7", 0, 0), |
| (0x8000, "Reserved8", 0, 0), |
| ] |
| |
| def _buildDict(): |
| d = {} |
| for mask, name, isDevice, signed in valueRecordFormat: |
| d[name] = mask, isDevice, signed |
| return d |
| |
| valueRecordFormatDict = _buildDict() |
| |
| |
| class ValueRecordFactory(object): |
| |
| """Given a format code, this object convert ValueRecords.""" |
| |
| def __init__(self, valueFormat): |
| format = [] |
| for mask, name, isDevice, signed in valueRecordFormat: |
| if valueFormat & mask: |
| format.append((name, isDevice, signed)) |
| self.format = format |
| |
| def __len__(self): |
| return len(self.format) |
| |
| def readValueRecord(self, reader, font): |
| format = self.format |
| if not format: |
| return None |
| valueRecord = ValueRecord() |
| for name, isDevice, signed in format: |
| if signed: |
| value = reader.readShort() |
| else: |
| value = reader.readUShort() |
| if isDevice: |
| if value: |
| from . import otTables |
| subReader = reader.getSubReader(value) |
| value = getattr(otTables, name)() |
| value.decompile(subReader, font) |
| else: |
| value = None |
| setattr(valueRecord, name, value) |
| return valueRecord |
| |
| def writeValueRecord(self, writer, font, valueRecord): |
| for name, isDevice, signed in self.format: |
| value = getattr(valueRecord, name, 0) |
| if isDevice: |
| if value: |
| subWriter = writer.getSubWriter() |
| writer.writeSubTable(subWriter) |
| value.compile(subWriter, font) |
| else: |
| writer.writeUShort(0) |
| elif signed: |
| writer.writeShort(value) |
| else: |
| writer.writeUShort(value) |
| |
| |
| class ValueRecord(object): |
| |
| # see ValueRecordFactory |
| |
| def __init__(self, valueFormat=None, src=None): |
| if valueFormat is not None: |
| for mask, name, isDevice, signed in valueRecordFormat: |
| if valueFormat & mask: |
| setattr(self, name, None if isDevice else 0) |
| if src is not None: |
| for key,val in src.__dict__.items(): |
| if not hasattr(self, key): |
| continue |
| setattr(self, key, val) |
| elif src is not None: |
| self.__dict__ = src.__dict__.copy() |
| |
| def getFormat(self): |
| format = 0 |
| for name in self.__dict__.keys(): |
| format = format | valueRecordFormatDict[name][0] |
| return format |
| |
| def getEffectiveFormat(self): |
| format = 0 |
| for name,value in self.__dict__.items(): |
| if value: |
| format = format | valueRecordFormatDict[name][0] |
| return format |
| |
| def toXML(self, xmlWriter, font, valueName, attrs=None): |
| if attrs is None: |
| simpleItems = [] |
| else: |
| simpleItems = list(attrs) |
| for mask, name, isDevice, format in valueRecordFormat[:4]: # "simple" values |
| if hasattr(self, name): |
| simpleItems.append((name, getattr(self, name))) |
| deviceItems = [] |
| for mask, name, isDevice, format in valueRecordFormat[4:8]: # device records |
| if hasattr(self, name): |
| device = getattr(self, name) |
| if device is not None: |
| deviceItems.append((name, device)) |
| if deviceItems: |
| xmlWriter.begintag(valueName, simpleItems) |
| xmlWriter.newline() |
| for name, deviceRecord in deviceItems: |
| if deviceRecord is not None: |
| deviceRecord.toXML(xmlWriter, font, name=name) |
| xmlWriter.endtag(valueName) |
| xmlWriter.newline() |
| else: |
| xmlWriter.simpletag(valueName, simpleItems) |
| xmlWriter.newline() |
| |
| def fromXML(self, name, attrs, content, font): |
| from . import otTables |
| for k, v in attrs.items(): |
| setattr(self, k, int(v)) |
| for element in content: |
| if not isinstance(element, tuple): |
| continue |
| name, attrs, content = element |
| value = getattr(otTables, name)() |
| for elem2 in content: |
| if not isinstance(elem2, tuple): |
| continue |
| name2, attrs2, content2 = elem2 |
| value.fromXML(name2, attrs2, content2, font) |
| setattr(self, name, value) |
| |
| def __ne__(self, other): |
| result = self.__eq__(other) |
| return result if result is NotImplemented else not result |
| |
| def __eq__(self, other): |
| if type(self) != type(other): |
| return NotImplemented |
| return self.__dict__ == other.__dict__ |