diff options
Diffstat (limited to 'Lib/fontTools/ttLib/woff2.py')
-rw-r--r-- | Lib/fontTools/ttLib/woff2.py | 2947 |
1 files changed, 1550 insertions, 1397 deletions
diff --git a/Lib/fontTools/ttLib/woff2.py b/Lib/fontTools/ttLib/woff2.py index b66661ab..9da2f7e6 100644 --- a/Lib/fontTools/ttLib/woff2.py +++ b/Lib/fontTools/ttLib/woff2.py @@ -6,11 +6,24 @@ from collections import OrderedDict from fontTools.misc import sstruct from fontTools.misc.arrayTools import calcIntBounds from fontTools.misc.textTools import Tag, bytechr, byteord, bytesjoin, pad -from fontTools.ttLib import (TTFont, TTLibError, getTableModule, getTableClass, - getSearchRange) -from fontTools.ttLib.sfnt import (SFNTReader, SFNTWriter, DirectoryEntry, - WOFFFlavorData, sfntDirectoryFormat, sfntDirectorySize, SFNTDirectoryEntry, - sfntDirectoryEntrySize, calcChecksum) +from fontTools.ttLib import ( + TTFont, + TTLibError, + getTableModule, + getTableClass, + getSearchRange, +) +from fontTools.ttLib.sfnt import ( + SFNTReader, + SFNTWriter, + DirectoryEntry, + WOFFFlavorData, + sfntDirectoryFormat, + sfntDirectorySize, + SFNTDirectoryEntry, + sfntDirectoryEntrySize, + calcChecksum, +) from fontTools.ttLib.tables import ttProgram, _g_l_y_f import logging @@ -19,454 +32,473 @@ log = logging.getLogger("fontTools.ttLib.woff2") haveBrotli = False try: - try: - import brotlicffi as brotli - except ImportError: - import brotli - haveBrotli = True + try: + import brotlicffi as brotli + except ImportError: + import brotli + haveBrotli = True except ImportError: - pass + pass class WOFF2Reader(SFNTReader): - - flavor = "woff2" - - def __init__(self, file, checkChecksums=0, fontNumber=-1): - if not haveBrotli: - log.error( - 'The WOFF2 decoder requires the Brotli Python extension, available at: ' - 'https://github.com/google/brotli') - raise ImportError("No module named brotli") - - self.file = file - - signature = Tag(self.file.read(4)) - if signature != b"wOF2": - raise TTLibError("Not a WOFF2 font (bad signature)") - - self.file.seek(0) - self.DirectoryEntry = WOFF2DirectoryEntry - data = self.file.read(woff2DirectorySize) - if len(data) != woff2DirectorySize: - raise TTLibError('Not a WOFF2 font (not enough data)') - sstruct.unpack(woff2DirectoryFormat, data, self) - - self.tables = OrderedDict() - offset = 0 - for i in range(self.numTables): - entry = self.DirectoryEntry() - entry.fromFile(self.file) - tag = Tag(entry.tag) - self.tables[tag] = entry - entry.offset = offset - offset += entry.length - - totalUncompressedSize = offset - compressedData = self.file.read(self.totalCompressedSize) - decompressedData = brotli.decompress(compressedData) - if len(decompressedData) != totalUncompressedSize: - raise TTLibError( - 'unexpected size for decompressed font data: expected %d, found %d' - % (totalUncompressedSize, len(decompressedData))) - self.transformBuffer = BytesIO(decompressedData) - - self.file.seek(0, 2) - if self.length != self.file.tell(): - raise TTLibError("reported 'length' doesn't match the actual file size") - - self.flavorData = WOFF2FlavorData(self) - - # make empty TTFont to store data while reconstructing tables - self.ttFont = TTFont(recalcBBoxes=False, recalcTimestamp=False) - - def __getitem__(self, tag): - """Fetch the raw table data. Reconstruct transformed tables.""" - entry = self.tables[Tag(tag)] - if not hasattr(entry, 'data'): - if entry.transformed: - entry.data = self.reconstructTable(tag) - else: - entry.data = entry.loadData(self.transformBuffer) - return entry.data - - def reconstructTable(self, tag): - """Reconstruct table named 'tag' from transformed data.""" - entry = self.tables[Tag(tag)] - rawData = entry.loadData(self.transformBuffer) - if tag == 'glyf': - # no need to pad glyph data when reconstructing - padding = self.padding if hasattr(self, 'padding') else None - data = self._reconstructGlyf(rawData, padding) - elif tag == 'loca': - data = self._reconstructLoca() - elif tag == 'hmtx': - data = self._reconstructHmtx(rawData) - else: - raise TTLibError("transform for table '%s' is unknown" % tag) - return data - - def _reconstructGlyf(self, data, padding=None): - """ Return recostructed glyf table data, and set the corresponding loca's - locations. Optionally pad glyph offsets to the specified number of bytes. - """ - self.ttFont['loca'] = WOFF2LocaTable() - glyfTable = self.ttFont['glyf'] = WOFF2GlyfTable() - glyfTable.reconstruct(data, self.ttFont) - if padding: - glyfTable.padding = padding - data = glyfTable.compile(self.ttFont) - return data - - def _reconstructLoca(self): - """ Return reconstructed loca table data. """ - if 'loca' not in self.ttFont: - # make sure glyf is reconstructed first - self.tables['glyf'].data = self.reconstructTable('glyf') - locaTable = self.ttFont['loca'] - data = locaTable.compile(self.ttFont) - if len(data) != self.tables['loca'].origLength: - raise TTLibError( - "reconstructed 'loca' table doesn't match original size: " - "expected %d, found %d" - % (self.tables['loca'].origLength, len(data))) - return data - - def _reconstructHmtx(self, data): - """ Return reconstructed hmtx table data. """ - # Before reconstructing 'hmtx' table we need to parse other tables: - # 'glyf' is required for reconstructing the sidebearings from the glyphs' - # bounding box; 'hhea' is needed for the numberOfHMetrics field. - if "glyf" in self.flavorData.transformedTables: - # transformed 'glyf' table is self-contained, thus 'loca' not needed - tableDependencies = ("maxp", "hhea", "glyf") - else: - # decompiling untransformed 'glyf' requires 'loca', which requires 'head' - tableDependencies = ("maxp", "head", "hhea", "loca", "glyf") - for tag in tableDependencies: - self._decompileTable(tag) - hmtxTable = self.ttFont["hmtx"] = WOFF2HmtxTable() - hmtxTable.reconstruct(data, self.ttFont) - data = hmtxTable.compile(self.ttFont) - return data - - def _decompileTable(self, tag): - """Decompile table data and store it inside self.ttFont.""" - data = self[tag] - if self.ttFont.isLoaded(tag): - return self.ttFont[tag] - tableClass = getTableClass(tag) - table = tableClass(tag) - self.ttFont.tables[tag] = table - table.decompile(data, self.ttFont) + flavor = "woff2" + + def __init__(self, file, checkChecksums=0, fontNumber=-1): + if not haveBrotli: + log.error( + "The WOFF2 decoder requires the Brotli Python extension, available at: " + "https://github.com/google/brotli" + ) + raise ImportError("No module named brotli") + + self.file = file + + signature = Tag(self.file.read(4)) + if signature != b"wOF2": + raise TTLibError("Not a WOFF2 font (bad signature)") + + self.file.seek(0) + self.DirectoryEntry = WOFF2DirectoryEntry + data = self.file.read(woff2DirectorySize) + if len(data) != woff2DirectorySize: + raise TTLibError("Not a WOFF2 font (not enough data)") + sstruct.unpack(woff2DirectoryFormat, data, self) + + self.tables = OrderedDict() + offset = 0 + for i in range(self.numTables): + entry = self.DirectoryEntry() + entry.fromFile(self.file) + tag = Tag(entry.tag) + self.tables[tag] = entry + entry.offset = offset + offset += entry.length + + totalUncompressedSize = offset + compressedData = self.file.read(self.totalCompressedSize) + decompressedData = brotli.decompress(compressedData) + if len(decompressedData) != totalUncompressedSize: + raise TTLibError( + "unexpected size for decompressed font data: expected %d, found %d" + % (totalUncompressedSize, len(decompressedData)) + ) + self.transformBuffer = BytesIO(decompressedData) + + self.file.seek(0, 2) + if self.length != self.file.tell(): + raise TTLibError("reported 'length' doesn't match the actual file size") + + self.flavorData = WOFF2FlavorData(self) + + # make empty TTFont to store data while reconstructing tables + self.ttFont = TTFont(recalcBBoxes=False, recalcTimestamp=False) + + def __getitem__(self, tag): + """Fetch the raw table data. Reconstruct transformed tables.""" + entry = self.tables[Tag(tag)] + if not hasattr(entry, "data"): + if entry.transformed: + entry.data = self.reconstructTable(tag) + else: + entry.data = entry.loadData(self.transformBuffer) + return entry.data + + def reconstructTable(self, tag): + """Reconstruct table named 'tag' from transformed data.""" + entry = self.tables[Tag(tag)] + rawData = entry.loadData(self.transformBuffer) + if tag == "glyf": + # no need to pad glyph data when reconstructing + padding = self.padding if hasattr(self, "padding") else None + data = self._reconstructGlyf(rawData, padding) + elif tag == "loca": + data = self._reconstructLoca() + elif tag == "hmtx": + data = self._reconstructHmtx(rawData) + else: + raise TTLibError("transform for table '%s' is unknown" % tag) + return data + + def _reconstructGlyf(self, data, padding=None): + """Return recostructed glyf table data, and set the corresponding loca's + locations. Optionally pad glyph offsets to the specified number of bytes. + """ + self.ttFont["loca"] = WOFF2LocaTable() + glyfTable = self.ttFont["glyf"] = WOFF2GlyfTable() + glyfTable.reconstruct(data, self.ttFont) + if padding: + glyfTable.padding = padding + data = glyfTable.compile(self.ttFont) + return data + + def _reconstructLoca(self): + """Return reconstructed loca table data.""" + if "loca" not in self.ttFont: + # make sure glyf is reconstructed first + self.tables["glyf"].data = self.reconstructTable("glyf") + locaTable = self.ttFont["loca"] + data = locaTable.compile(self.ttFont) + if len(data) != self.tables["loca"].origLength: + raise TTLibError( + "reconstructed 'loca' table doesn't match original size: " + "expected %d, found %d" % (self.tables["loca"].origLength, len(data)) + ) + return data + + def _reconstructHmtx(self, data): + """Return reconstructed hmtx table data.""" + # Before reconstructing 'hmtx' table we need to parse other tables: + # 'glyf' is required for reconstructing the sidebearings from the glyphs' + # bounding box; 'hhea' is needed for the numberOfHMetrics field. + if "glyf" in self.flavorData.transformedTables: + # transformed 'glyf' table is self-contained, thus 'loca' not needed + tableDependencies = ("maxp", "hhea", "glyf") + else: + # decompiling untransformed 'glyf' requires 'loca', which requires 'head' + tableDependencies = ("maxp", "head", "hhea", "loca", "glyf") + for tag in tableDependencies: + self._decompileTable(tag) + hmtxTable = self.ttFont["hmtx"] = WOFF2HmtxTable() + hmtxTable.reconstruct(data, self.ttFont) + data = hmtxTable.compile(self.ttFont) + return data + + def _decompileTable(self, tag): + """Decompile table data and store it inside self.ttFont.""" + data = self[tag] + if self.ttFont.isLoaded(tag): + return self.ttFont[tag] + tableClass = getTableClass(tag) + table = tableClass(tag) + self.ttFont.tables[tag] = table + table.decompile(data, self.ttFont) class WOFF2Writer(SFNTWriter): - - flavor = "woff2" - - def __init__(self, file, numTables, sfntVersion="\000\001\000\000", - flavor=None, flavorData=None): - if not haveBrotli: - log.error( - 'The WOFF2 encoder requires the Brotli Python extension, available at: ' - 'https://github.com/google/brotli') - raise ImportError("No module named brotli") - - self.file = file - self.numTables = numTables - self.sfntVersion = Tag(sfntVersion) - self.flavorData = WOFF2FlavorData(data=flavorData) - - self.directoryFormat = woff2DirectoryFormat - self.directorySize = woff2DirectorySize - self.DirectoryEntry = WOFF2DirectoryEntry - - self.signature = Tag("wOF2") - - self.nextTableOffset = 0 - self.transformBuffer = BytesIO() - - self.tables = OrderedDict() - - # make empty TTFont to store data while normalising and transforming tables - self.ttFont = TTFont(recalcBBoxes=False, recalcTimestamp=False) - - def __setitem__(self, tag, data): - """Associate new entry named 'tag' with raw table data.""" - if tag in self.tables: - raise TTLibError("cannot rewrite '%s' table" % tag) - if tag == 'DSIG': - # always drop DSIG table, since the encoding process can invalidate it - self.numTables -= 1 - return - - entry = self.DirectoryEntry() - entry.tag = Tag(tag) - entry.flags = getKnownTagIndex(entry.tag) - # WOFF2 table data are written to disk only on close(), after all tags - # have been specified - entry.data = data - - self.tables[tag] = entry - - def close(self): - """ All tags must have been specified. Now write the table data and directory. - """ - if len(self.tables) != self.numTables: - raise TTLibError("wrong number of tables; expected %d, found %d" % (self.numTables, len(self.tables))) - - if self.sfntVersion in ("\x00\x01\x00\x00", "true"): - isTrueType = True - elif self.sfntVersion == "OTTO": - isTrueType = False - else: - raise TTLibError("Not a TrueType or OpenType font (bad sfntVersion)") - - # The WOFF2 spec no longer requires the glyph offsets to be 4-byte aligned. - # However, the reference WOFF2 implementation still fails to reconstruct - # 'unpadded' glyf tables, therefore we need to 'normalise' them. - # See: - # https://github.com/khaledhosny/ots/issues/60 - # https://github.com/google/woff2/issues/15 - if ( - isTrueType - and "glyf" in self.flavorData.transformedTables - and "glyf" in self.tables - ): - self._normaliseGlyfAndLoca(padding=4) - self._setHeadTransformFlag() - - # To pass the legacy OpenType Sanitiser currently included in browsers, - # we must sort the table directory and data alphabetically by tag. - # See: - # https://github.com/google/woff2/pull/3 - # https://lists.w3.org/Archives/Public/public-webfonts-wg/2015Mar/0000.html - # TODO(user): remove to match spec once browsers are on newer OTS - self.tables = OrderedDict(sorted(self.tables.items())) - - self.totalSfntSize = self._calcSFNTChecksumsLengthsAndOffsets() - - fontData = self._transformTables() - compressedFont = brotli.compress(fontData, mode=brotli.MODE_FONT) - - self.totalCompressedSize = len(compressedFont) - self.length = self._calcTotalSize() - self.majorVersion, self.minorVersion = self._getVersion() - self.reserved = 0 - - directory = self._packTableDirectory() - self.file.seek(0) - self.file.write(pad(directory + compressedFont, size=4)) - self._writeFlavorData() - - def _normaliseGlyfAndLoca(self, padding=4): - """ Recompile glyf and loca tables, aligning glyph offsets to multiples of - 'padding' size. Update the head table's 'indexToLocFormat' accordingly while - compiling loca. - """ - if self.sfntVersion == "OTTO": - return - - for tag in ('maxp', 'head', 'loca', 'glyf'): - self._decompileTable(tag) - self.ttFont['glyf'].padding = padding - for tag in ('glyf', 'loca'): - self._compileTable(tag) - - def _setHeadTransformFlag(self): - """ Set bit 11 of 'head' table flags to indicate that the font has undergone - a lossless modifying transform. Re-compile head table data.""" - self._decompileTable('head') - self.ttFont['head'].flags |= (1 << 11) - self._compileTable('head') - - def _decompileTable(self, tag): - """ Fetch table data, decompile it, and store it inside self.ttFont. """ - tag = Tag(tag) - if tag not in self.tables: - raise TTLibError("missing required table: %s" % tag) - if self.ttFont.isLoaded(tag): - return - data = self.tables[tag].data - if tag == 'loca': - tableClass = WOFF2LocaTable - elif tag == 'glyf': - tableClass = WOFF2GlyfTable - elif tag == 'hmtx': - tableClass = WOFF2HmtxTable - else: - tableClass = getTableClass(tag) - table = tableClass(tag) - self.ttFont.tables[tag] = table - table.decompile(data, self.ttFont) - - def _compileTable(self, tag): - """ Compile table and store it in its 'data' attribute. """ - self.tables[tag].data = self.ttFont[tag].compile(self.ttFont) - - def _calcSFNTChecksumsLengthsAndOffsets(self): - """ Compute the 'original' SFNT checksums, lengths and offsets for checksum - adjustment calculation. Return the total size of the uncompressed font. - """ - offset = sfntDirectorySize + sfntDirectoryEntrySize * len(self.tables) - for tag, entry in self.tables.items(): - data = entry.data - entry.origOffset = offset - entry.origLength = len(data) - if tag == 'head': - entry.checkSum = calcChecksum(data[:8] + b'\0\0\0\0' + data[12:]) - else: - entry.checkSum = calcChecksum(data) - offset += (entry.origLength + 3) & ~3 - return offset - - def _transformTables(self): - """Return transformed font data.""" - transformedTables = self.flavorData.transformedTables - for tag, entry in self.tables.items(): - data = None - if tag in transformedTables: - data = self.transformTable(tag) - if data is not None: - entry.transformed = True - if data is None: - # pass-through the table data without transformation - data = entry.data - entry.transformed = False - entry.offset = self.nextTableOffset - entry.saveData(self.transformBuffer, data) - self.nextTableOffset += entry.length - self.writeMasterChecksum() - fontData = self.transformBuffer.getvalue() - return fontData - - def transformTable(self, tag): - """Return transformed table data, or None if some pre-conditions aren't - met -- in which case, the non-transformed table data will be used. - """ - if tag == "loca": - data = b"" - elif tag == "glyf": - for tag in ('maxp', 'head', 'loca', 'glyf'): - self._decompileTable(tag) - glyfTable = self.ttFont['glyf'] - data = glyfTable.transform(self.ttFont) - elif tag == "hmtx": - if "glyf" not in self.tables: - return - for tag in ("maxp", "head", "hhea", "loca", "glyf", "hmtx"): - self._decompileTable(tag) - hmtxTable = self.ttFont["hmtx"] - data = hmtxTable.transform(self.ttFont) # can be None - else: - raise TTLibError("Transform for table '%s' is unknown" % tag) - return data - - def _calcMasterChecksum(self): - """Calculate checkSumAdjustment.""" - tags = list(self.tables.keys()) - checksums = [] - for i in range(len(tags)): - checksums.append(self.tables[tags[i]].checkSum) - - # Create a SFNT directory for checksum calculation purposes - self.searchRange, self.entrySelector, self.rangeShift = getSearchRange(self.numTables, 16) - directory = sstruct.pack(sfntDirectoryFormat, self) - tables = sorted(self.tables.items()) - for tag, entry in tables: - sfntEntry = SFNTDirectoryEntry() - sfntEntry.tag = entry.tag - sfntEntry.checkSum = entry.checkSum - sfntEntry.offset = entry.origOffset - sfntEntry.length = entry.origLength - directory = directory + sfntEntry.toString() - - directory_end = sfntDirectorySize + len(self.tables) * sfntDirectoryEntrySize - assert directory_end == len(directory) - - checksums.append(calcChecksum(directory)) - checksum = sum(checksums) & 0xffffffff - # BiboAfba! - checksumadjustment = (0xB1B0AFBA - checksum) & 0xffffffff - return checksumadjustment - - def writeMasterChecksum(self): - """Write checkSumAdjustment to the transformBuffer.""" - checksumadjustment = self._calcMasterChecksum() - self.transformBuffer.seek(self.tables['head'].offset + 8) - self.transformBuffer.write(struct.pack(">L", checksumadjustment)) - - def _calcTotalSize(self): - """Calculate total size of WOFF2 font, including any meta- and/or private data.""" - offset = self.directorySize - for entry in self.tables.values(): - offset += len(entry.toString()) - offset += self.totalCompressedSize - offset = (offset + 3) & ~3 - offset = self._calcFlavorDataOffsetsAndSize(offset) - return offset - - def _calcFlavorDataOffsetsAndSize(self, start): - """Calculate offsets and lengths for any meta- and/or private data.""" - offset = start - data = self.flavorData - if data.metaData: - self.metaOrigLength = len(data.metaData) - self.metaOffset = offset - self.compressedMetaData = brotli.compress( - data.metaData, mode=brotli.MODE_TEXT) - self.metaLength = len(self.compressedMetaData) - offset += self.metaLength - else: - self.metaOffset = self.metaLength = self.metaOrigLength = 0 - self.compressedMetaData = b"" - if data.privData: - # make sure private data is padded to 4-byte boundary - offset = (offset + 3) & ~3 - self.privOffset = offset - self.privLength = len(data.privData) - offset += self.privLength - else: - self.privOffset = self.privLength = 0 - return offset - - def _getVersion(self): - """Return the WOFF2 font's (majorVersion, minorVersion) tuple.""" - data = self.flavorData - if data.majorVersion is not None and data.minorVersion is not None: - return data.majorVersion, data.minorVersion - else: - # if None, return 'fontRevision' from 'head' table - if 'head' in self.tables: - return struct.unpack(">HH", self.tables['head'].data[4:8]) - else: - return 0, 0 - - def _packTableDirectory(self): - """Return WOFF2 table directory data.""" - directory = sstruct.pack(self.directoryFormat, self) - for entry in self.tables.values(): - directory = directory + entry.toString() - return directory - - def _writeFlavorData(self): - """Write metadata and/or private data using appropiate padding.""" - compressedMetaData = self.compressedMetaData - privData = self.flavorData.privData - if compressedMetaData and privData: - compressedMetaData = pad(compressedMetaData, size=4) - if compressedMetaData: - self.file.seek(self.metaOffset) - assert self.file.tell() == self.metaOffset - self.file.write(compressedMetaData) - if privData: - self.file.seek(self.privOffset) - assert self.file.tell() == self.privOffset - self.file.write(privData) - - def reordersTables(self): - return True + flavor = "woff2" + + def __init__( + self, + file, + numTables, + sfntVersion="\000\001\000\000", + flavor=None, + flavorData=None, + ): + if not haveBrotli: + log.error( + "The WOFF2 encoder requires the Brotli Python extension, available at: " + "https://github.com/google/brotli" + ) + raise ImportError("No module named brotli") + + self.file = file + self.numTables = numTables + self.sfntVersion = Tag(sfntVersion) + self.flavorData = WOFF2FlavorData(data=flavorData) + + self.directoryFormat = woff2DirectoryFormat + self.directorySize = woff2DirectorySize + self.DirectoryEntry = WOFF2DirectoryEntry + + self.signature = Tag("wOF2") + + self.nextTableOffset = 0 + self.transformBuffer = BytesIO() + + self.tables = OrderedDict() + + # make empty TTFont to store data while normalising and transforming tables + self.ttFont = TTFont(recalcBBoxes=False, recalcTimestamp=False) + + def __setitem__(self, tag, data): + """Associate new entry named 'tag' with raw table data.""" + if tag in self.tables: + raise TTLibError("cannot rewrite '%s' table" % tag) + if tag == "DSIG": + # always drop DSIG table, since the encoding process can invalidate it + self.numTables -= 1 + return + + entry = self.DirectoryEntry() + entry.tag = Tag(tag) + entry.flags = getKnownTagIndex(entry.tag) + # WOFF2 table data are written to disk only on close(), after all tags + # have been specified + entry.data = data + + self.tables[tag] = entry + + def close(self): + """All tags must have been specified. Now write the table data and directory.""" + if len(self.tables) != self.numTables: + raise TTLibError( + "wrong number of tables; expected %d, found %d" + % (self.numTables, len(self.tables)) + ) + + if self.sfntVersion in ("\x00\x01\x00\x00", "true"): + isTrueType = True + elif self.sfntVersion == "OTTO": + isTrueType = False + else: + raise TTLibError("Not a TrueType or OpenType font (bad sfntVersion)") + + # The WOFF2 spec no longer requires the glyph offsets to be 4-byte aligned. + # However, the reference WOFF2 implementation still fails to reconstruct + # 'unpadded' glyf tables, therefore we need to 'normalise' them. + # See: + # https://github.com/khaledhosny/ots/issues/60 + # https://github.com/google/woff2/issues/15 + if ( + isTrueType + and "glyf" in self.flavorData.transformedTables + and "glyf" in self.tables + ): + self._normaliseGlyfAndLoca(padding=4) + self._setHeadTransformFlag() + + # To pass the legacy OpenType Sanitiser currently included in browsers, + # we must sort the table directory and data alphabetically by tag. + # See: + # https://github.com/google/woff2/pull/3 + # https://lists.w3.org/Archives/Public/public-webfonts-wg/2015Mar/0000.html + # + # 2023: We rely on this in _transformTables where we expect that + # "loca" comes after "glyf" table. + self.tables = OrderedDict(sorted(self.tables.items())) + + self.totalSfntSize = self._calcSFNTChecksumsLengthsAndOffsets() + + fontData = self._transformTables() + compressedFont = brotli.compress(fontData, mode=brotli.MODE_FONT) + + self.totalCompressedSize = len(compressedFont) + self.length = self._calcTotalSize() + self.majorVersion, self.minorVersion = self._getVersion() + self.reserved = 0 + + directory = self._packTableDirectory() + self.file.seek(0) + self.file.write(pad(directory + compressedFont, size=4)) + self._writeFlavorData() + + def _normaliseGlyfAndLoca(self, padding=4): + """Recompile glyf and loca tables, aligning glyph offsets to multiples of + 'padding' size. Update the head table's 'indexToLocFormat' accordingly while + compiling loca. + """ + if self.sfntVersion == "OTTO": + return + + for tag in ("maxp", "head", "loca", "glyf", "fvar"): + if tag in self.tables: + self._decompileTable(tag) + self.ttFont["glyf"].padding = padding + for tag in ("glyf", "loca"): + self._compileTable(tag) + + def _setHeadTransformFlag(self): + """Set bit 11 of 'head' table flags to indicate that the font has undergone + a lossless modifying transform. Re-compile head table data.""" + self._decompileTable("head") + self.ttFont["head"].flags |= 1 << 11 + self._compileTable("head") + + def _decompileTable(self, tag): + """Fetch table data, decompile it, and store it inside self.ttFont.""" + tag = Tag(tag) + if tag not in self.tables: + raise TTLibError("missing required table: %s" % tag) + if self.ttFont.isLoaded(tag): + return + data = self.tables[tag].data + if tag == "loca": + tableClass = WOFF2LocaTable + elif tag == "glyf": + tableClass = WOFF2GlyfTable + elif tag == "hmtx": + tableClass = WOFF2HmtxTable + else: + tableClass = getTableClass(tag) + table = tableClass(tag) + self.ttFont.tables[tag] = table + table.decompile(data, self.ttFont) + + def _compileTable(self, tag): + """Compile table and store it in its 'data' attribute.""" + self.tables[tag].data = self.ttFont[tag].compile(self.ttFont) + + def _calcSFNTChecksumsLengthsAndOffsets(self): + """Compute the 'original' SFNT checksums, lengths and offsets for checksum + adjustment calculation. Return the total size of the uncompressed font. + """ + offset = sfntDirectorySize + sfntDirectoryEntrySize * len(self.tables) + for tag, entry in self.tables.items(): + data = entry.data + entry.origOffset = offset + entry.origLength = len(data) + if tag == "head": + entry.checkSum = calcChecksum(data[:8] + b"\0\0\0\0" + data[12:]) + else: + entry.checkSum = calcChecksum(data) + offset += (entry.origLength + 3) & ~3 + return offset + + def _transformTables(self): + """Return transformed font data.""" + transformedTables = self.flavorData.transformedTables + for tag, entry in self.tables.items(): + data = None + if tag in transformedTables: + data = self.transformTable(tag) + if data is not None: + entry.transformed = True + if data is None: + if tag == "glyf": + # Currently we always sort table tags so + # 'loca' comes after 'glyf'. + transformedTables.discard("loca") + # pass-through the table data without transformation + data = entry.data + entry.transformed = False + entry.offset = self.nextTableOffset + entry.saveData(self.transformBuffer, data) + self.nextTableOffset += entry.length + self.writeMasterChecksum() + fontData = self.transformBuffer.getvalue() + return fontData + + def transformTable(self, tag): + """Return transformed table data, or None if some pre-conditions aren't + met -- in which case, the non-transformed table data will be used. + """ + if tag == "loca": + data = b"" + elif tag == "glyf": + for tag in ("maxp", "head", "loca", "glyf"): + self._decompileTable(tag) + glyfTable = self.ttFont["glyf"] + data = glyfTable.transform(self.ttFont) + elif tag == "hmtx": + if "glyf" not in self.tables: + return + for tag in ("maxp", "head", "hhea", "loca", "glyf", "hmtx"): + self._decompileTable(tag) + hmtxTable = self.ttFont["hmtx"] + data = hmtxTable.transform(self.ttFont) # can be None + else: + raise TTLibError("Transform for table '%s' is unknown" % tag) + return data + + def _calcMasterChecksum(self): + """Calculate checkSumAdjustment.""" + tags = list(self.tables.keys()) + checksums = [] + for i in range(len(tags)): + checksums.append(self.tables[tags[i]].checkSum) + + # Create a SFNT directory for checksum calculation purposes + self.searchRange, self.entrySelector, self.rangeShift = getSearchRange( + self.numTables, 16 + ) + directory = sstruct.pack(sfntDirectoryFormat, self) + tables = sorted(self.tables.items()) + for tag, entry in tables: + sfntEntry = SFNTDirectoryEntry() + sfntEntry.tag = entry.tag + sfntEntry.checkSum = entry.checkSum + sfntEntry.offset = entry.origOffset + sfntEntry.length = entry.origLength + directory = directory + sfntEntry.toString() + + directory_end = sfntDirectorySize + len(self.tables) * sfntDirectoryEntrySize + assert directory_end == len(directory) + + checksums.append(calcChecksum(directory)) + checksum = sum(checksums) & 0xFFFFFFFF + # BiboAfba! + checksumadjustment = (0xB1B0AFBA - checksum) & 0xFFFFFFFF + return checksumadjustment + + def writeMasterChecksum(self): + """Write checkSumAdjustment to the transformBuffer.""" + checksumadjustment = self._calcMasterChecksum() + self.transformBuffer.seek(self.tables["head"].offset + 8) + self.transformBuffer.write(struct.pack(">L", checksumadjustment)) + + def _calcTotalSize(self): + """Calculate total size of WOFF2 font, including any meta- and/or private data.""" + offset = self.directorySize + for entry in self.tables.values(): + offset += len(entry.toString()) + offset += self.totalCompressedSize + offset = (offset + 3) & ~3 + offset = self._calcFlavorDataOffsetsAndSize(offset) + return offset + + def _calcFlavorDataOffsetsAndSize(self, start): + """Calculate offsets and lengths for any meta- and/or private data.""" + offset = start + data = self.flavorData + if data.metaData: + self.metaOrigLength = len(data.metaData) + self.metaOffset = offset + self.compressedMetaData = brotli.compress( + data.metaData, mode=brotli.MODE_TEXT + ) + self.metaLength = len(self.compressedMetaData) + offset += self.metaLength + else: + self.metaOffset = self.metaLength = self.metaOrigLength = 0 + self.compressedMetaData = b"" + if data.privData: + # make sure private data is padded to 4-byte boundary + offset = (offset + 3) & ~3 + self.privOffset = offset + self.privLength = len(data.privData) + offset += self.privLength + else: + self.privOffset = self.privLength = 0 + return offset + + def _getVersion(self): + """Return the WOFF2 font's (majorVersion, minorVersion) tuple.""" + data = self.flavorData + if data.majorVersion is not None and data.minorVersion is not None: + return data.majorVersion, data.minorVersion + else: + # if None, return 'fontRevision' from 'head' table + if "head" in self.tables: + return struct.unpack(">HH", self.tables["head"].data[4:8]) + else: + return 0, 0 + + def _packTableDirectory(self): + """Return WOFF2 table directory data.""" + directory = sstruct.pack(self.directoryFormat, self) + for entry in self.tables.values(): + directory = directory + entry.toString() + return directory + + def _writeFlavorData(self): + """Write metadata and/or private data using appropiate padding.""" + compressedMetaData = self.compressedMetaData + privData = self.flavorData.privData + if compressedMetaData and privData: + compressedMetaData = pad(compressedMetaData, size=4) + if compressedMetaData: + self.file.seek(self.metaOffset) + assert self.file.tell() == self.metaOffset + self.file.write(compressedMetaData) + if privData: + self.file.seek(self.privOffset) + assert self.file.tell() == self.privOffset + self.file.write(privData) + + def reordersTables(self): + return True # -- woff2 directory helpers and cruft @@ -492,13 +524,70 @@ woff2DirectoryFormat = """ woff2DirectorySize = sstruct.calcsize(woff2DirectoryFormat) woff2KnownTags = ( - "cmap", "head", "hhea", "hmtx", "maxp", "name", "OS/2", "post", "cvt ", - "fpgm", "glyf", "loca", "prep", "CFF ", "VORG", "EBDT", "EBLC", "gasp", - "hdmx", "kern", "LTSH", "PCLT", "VDMX", "vhea", "vmtx", "BASE", "GDEF", - "GPOS", "GSUB", "EBSC", "JSTF", "MATH", "CBDT", "CBLC", "COLR", "CPAL", - "SVG ", "sbix", "acnt", "avar", "bdat", "bloc", "bsln", "cvar", "fdsc", - "feat", "fmtx", "fvar", "gvar", "hsty", "just", "lcar", "mort", "morx", - "opbd", "prop", "trak", "Zapf", "Silf", "Glat", "Gloc", "Feat", "Sill") + "cmap", + "head", + "hhea", + "hmtx", + "maxp", + "name", + "OS/2", + "post", + "cvt ", + "fpgm", + "glyf", + "loca", + "prep", + "CFF ", + "VORG", + "EBDT", + "EBLC", + "gasp", + "hdmx", + "kern", + "LTSH", + "PCLT", + "VDMX", + "vhea", + "vmtx", + "BASE", + "GDEF", + "GPOS", + "GSUB", + "EBSC", + "JSTF", + "MATH", + "CBDT", + "CBLC", + "COLR", + "CPAL", + "SVG ", + "sbix", + "acnt", + "avar", + "bdat", + "bloc", + "bsln", + "cvar", + "fdsc", + "feat", + "fmtx", + "fvar", + "gvar", + "hsty", + "just", + "lcar", + "mort", + "morx", + "opbd", + "prop", + "trak", + "Zapf", + "Silf", + "Glat", + "Gloc", + "Feat", + "Sill", +) woff2FlagsFormat = """ > # big endian @@ -517,13 +606,16 @@ woff2UnknownTagSize = sstruct.calcsize(woff2UnknownTagFormat) woff2UnknownTagIndex = 0x3F woff2Base128MaxSize = 5 -woff2DirectoryEntryMaxSize = woff2FlagsSize + woff2UnknownTagSize + 2 * woff2Base128MaxSize +woff2DirectoryEntryMaxSize = ( + woff2FlagsSize + woff2UnknownTagSize + 2 * woff2Base128MaxSize +) -woff2TransformedTableTags = ('glyf', 'loca') +woff2TransformedTableTags = ("glyf", "loca") woff2GlyfTableFormat = """ > # big endian - version: L # = 0x00000000 + version: H # = 0x0000 + optionFlags: H # Bit 0: we have overlapSimpleBitmap[], Bits 1-15: reserved numGlyphs: H # Number of glyphs indexFormat: H # Offset format for loca table nContourStreamSize: L # Size of nContour stream @@ -545,988 +637,1049 @@ bboxFormat = """ yMax: h """ +woff2OverlapSimpleBitmapFlag = 0x0001 + def getKnownTagIndex(tag): - """Return index of 'tag' in woff2KnownTags list. Return 63 if not found.""" - for i in range(len(woff2KnownTags)): - if tag == woff2KnownTags[i]: - return i - return woff2UnknownTagIndex + """Return index of 'tag' in woff2KnownTags list. Return 63 if not found.""" + for i in range(len(woff2KnownTags)): + if tag == woff2KnownTags[i]: + return i + return woff2UnknownTagIndex class WOFF2DirectoryEntry(DirectoryEntry): - - def fromFile(self, file): - pos = file.tell() - data = file.read(woff2DirectoryEntryMaxSize) - left = self.fromString(data) - consumed = len(data) - len(left) - file.seek(pos + consumed) - - def fromString(self, data): - if len(data) < 1: - raise TTLibError("can't read table 'flags': not enough data") - dummy, data = sstruct.unpack2(woff2FlagsFormat, data, self) - if self.flags & 0x3F == 0x3F: - # if bits [0..5] of the flags byte == 63, read a 4-byte arbitrary tag value - if len(data) < woff2UnknownTagSize: - raise TTLibError("can't read table 'tag': not enough data") - dummy, data = sstruct.unpack2(woff2UnknownTagFormat, data, self) - else: - # otherwise, tag is derived from a fixed 'Known Tags' table - self.tag = woff2KnownTags[self.flags & 0x3F] - self.tag = Tag(self.tag) - self.origLength, data = unpackBase128(data) - self.length = self.origLength - if self.transformed: - self.length, data = unpackBase128(data) - if self.tag == 'loca' and self.length != 0: - raise TTLibError( - "the transformLength of the 'loca' table must be 0") - # return left over data - return data - - def toString(self): - data = bytechr(self.flags) - if (self.flags & 0x3F) == 0x3F: - data += struct.pack('>4s', self.tag.tobytes()) - data += packBase128(self.origLength) - if self.transformed: - data += packBase128(self.length) - return data - - @property - def transformVersion(self): - """Return bits 6-7 of table entry's flags, which indicate the preprocessing - transformation version number (between 0 and 3). - """ - return self.flags >> 6 - - @transformVersion.setter - def transformVersion(self, value): - assert 0 <= value <= 3 - self.flags |= value << 6 - - @property - def transformed(self): - """Return True if the table has any transformation, else return False.""" - # For all tables in a font, except for 'glyf' and 'loca', the transformation - # version 0 indicates the null transform (where the original table data is - # passed directly to the Brotli compressor). For 'glyf' and 'loca' tables, - # transformation version 3 indicates the null transform - if self.tag in {"glyf", "loca"}: - return self.transformVersion != 3 - else: - return self.transformVersion != 0 - - @transformed.setter - def transformed(self, booleanValue): - # here we assume that a non-null transform means version 0 for 'glyf' and - # 'loca' and 1 for every other table (e.g. hmtx); but that may change as - # new transformation formats are introduced in the future (if ever). - if self.tag in {"glyf", "loca"}: - self.transformVersion = 3 if not booleanValue else 0 - else: - self.transformVersion = int(booleanValue) - - -class WOFF2LocaTable(getTableClass('loca')): - """Same as parent class. The only difference is that it attempts to preserve - the 'indexFormat' as encoded in the WOFF2 glyf table. - """ - - def __init__(self, tag=None): - self.tableTag = Tag(tag or 'loca') - - def compile(self, ttFont): - try: - max_location = max(self.locations) - except AttributeError: - self.set([]) - max_location = 0 - if 'glyf' in ttFont and hasattr(ttFont['glyf'], 'indexFormat'): - # copile loca using the indexFormat specified in the WOFF2 glyf table - indexFormat = ttFont['glyf'].indexFormat - if indexFormat == 0: - if max_location >= 0x20000: - raise TTLibError("indexFormat is 0 but local offsets > 0x20000") - if not all(l % 2 == 0 for l in self.locations): - raise TTLibError("indexFormat is 0 but local offsets not multiples of 2") - locations = array.array("H") - for i in range(len(self.locations)): - locations.append(self.locations[i] // 2) - else: - locations = array.array("I", self.locations) - if sys.byteorder != "big": locations.byteswap() - data = locations.tobytes() - else: - # use the most compact indexFormat given the current glyph offsets - data = super(WOFF2LocaTable, self).compile(ttFont) - return data - - -class WOFF2GlyfTable(getTableClass('glyf')): - """Decoder/Encoder for WOFF2 'glyf' table transform.""" - - subStreams = ( - 'nContourStream', 'nPointsStream', 'flagStream', 'glyphStream', - 'compositeStream', 'bboxStream', 'instructionStream') - - def __init__(self, tag=None): - self.tableTag = Tag(tag or 'glyf') - - def reconstruct(self, data, ttFont): - """ Decompile transformed 'glyf' data. """ - inputDataSize = len(data) - - if inputDataSize < woff2GlyfTableFormatSize: - raise TTLibError("not enough 'glyf' data") - dummy, data = sstruct.unpack2(woff2GlyfTableFormat, data, self) - offset = woff2GlyfTableFormatSize - - for stream in self.subStreams: - size = getattr(self, stream + 'Size') - setattr(self, stream, data[:size]) - data = data[size:] - offset += size - - if offset != inputDataSize: - raise TTLibError( - "incorrect size of transformed 'glyf' table: expected %d, received %d bytes" - % (offset, inputDataSize)) - - bboxBitmapSize = ((self.numGlyphs + 31) >> 5) << 2 - bboxBitmap = self.bboxStream[:bboxBitmapSize] - self.bboxBitmap = array.array('B', bboxBitmap) - self.bboxStream = self.bboxStream[bboxBitmapSize:] - - self.nContourStream = array.array("h", self.nContourStream) - if sys.byteorder != "big": self.nContourStream.byteswap() - assert len(self.nContourStream) == self.numGlyphs - - if 'head' in ttFont: - ttFont['head'].indexToLocFormat = self.indexFormat - try: - self.glyphOrder = ttFont.getGlyphOrder() - except: - self.glyphOrder = None - if self.glyphOrder is None: - self.glyphOrder = [".notdef"] - self.glyphOrder.extend(["glyph%.5d" % i for i in range(1, self.numGlyphs)]) - else: - if len(self.glyphOrder) != self.numGlyphs: - raise TTLibError( - "incorrect glyphOrder: expected %d glyphs, found %d" % - (len(self.glyphOrder), self.numGlyphs)) - - glyphs = self.glyphs = {} - for glyphID, glyphName in enumerate(self.glyphOrder): - glyph = self._decodeGlyph(glyphID) - glyphs[glyphName] = glyph - - def transform(self, ttFont): - """ Return transformed 'glyf' data """ - self.numGlyphs = len(self.glyphs) - assert len(self.glyphOrder) == self.numGlyphs - if 'maxp' in ttFont: - ttFont['maxp'].numGlyphs = self.numGlyphs - self.indexFormat = ttFont['head'].indexToLocFormat - - for stream in self.subStreams: - setattr(self, stream, b"") - bboxBitmapSize = ((self.numGlyphs + 31) >> 5) << 2 - self.bboxBitmap = array.array('B', [0]*bboxBitmapSize) - - for glyphID in range(self.numGlyphs): - self._encodeGlyph(glyphID) - - self.bboxStream = self.bboxBitmap.tobytes() + self.bboxStream - for stream in self.subStreams: - setattr(self, stream + 'Size', len(getattr(self, stream))) - self.version = 0 - data = sstruct.pack(woff2GlyfTableFormat, self) - data += bytesjoin([getattr(self, s) for s in self.subStreams]) - return data - - def _decodeGlyph(self, glyphID): - glyph = getTableModule('glyf').Glyph() - glyph.numberOfContours = self.nContourStream[glyphID] - if glyph.numberOfContours == 0: - return glyph - elif glyph.isComposite(): - self._decodeComponents(glyph) - else: - self._decodeCoordinates(glyph) - self._decodeBBox(glyphID, glyph) - return glyph - - def _decodeComponents(self, glyph): - data = self.compositeStream - glyph.components = [] - more = 1 - haveInstructions = 0 - while more: - component = getTableModule('glyf').GlyphComponent() - more, haveInstr, data = component.decompile(data, self) - haveInstructions = haveInstructions | haveInstr - glyph.components.append(component) - self.compositeStream = data - if haveInstructions: - self._decodeInstructions(glyph) - - def _decodeCoordinates(self, glyph): - data = self.nPointsStream - endPtsOfContours = [] - endPoint = -1 - for i in range(glyph.numberOfContours): - ptsOfContour, data = unpack255UShort(data) - endPoint += ptsOfContour - endPtsOfContours.append(endPoint) - glyph.endPtsOfContours = endPtsOfContours - self.nPointsStream = data - self._decodeTriplets(glyph) - self._decodeInstructions(glyph) - - def _decodeInstructions(self, glyph): - glyphStream = self.glyphStream - instructionStream = self.instructionStream - instructionLength, glyphStream = unpack255UShort(glyphStream) - glyph.program = ttProgram.Program() - glyph.program.fromBytecode(instructionStream[:instructionLength]) - self.glyphStream = glyphStream - self.instructionStream = instructionStream[instructionLength:] - - def _decodeBBox(self, glyphID, glyph): - haveBBox = bool(self.bboxBitmap[glyphID >> 3] & (0x80 >> (glyphID & 7))) - if glyph.isComposite() and not haveBBox: - raise TTLibError('no bbox values for composite glyph %d' % glyphID) - if haveBBox: - dummy, self.bboxStream = sstruct.unpack2(bboxFormat, self.bboxStream, glyph) - else: - glyph.recalcBounds(self) - - def _decodeTriplets(self, glyph): - - def withSign(flag, baseval): - assert 0 <= baseval and baseval < 65536, 'integer overflow' - return baseval if flag & 1 else -baseval - - nPoints = glyph.endPtsOfContours[-1] + 1 - flagSize = nPoints - if flagSize > len(self.flagStream): - raise TTLibError("not enough 'flagStream' data") - flagsData = self.flagStream[:flagSize] - self.flagStream = self.flagStream[flagSize:] - flags = array.array('B', flagsData) - - triplets = array.array('B', self.glyphStream) - nTriplets = len(triplets) - assert nPoints <= nTriplets - - x = 0 - y = 0 - glyph.coordinates = getTableModule('glyf').GlyphCoordinates.zeros(nPoints) - glyph.flags = array.array("B") - tripletIndex = 0 - for i in range(nPoints): - flag = flags[i] - onCurve = not bool(flag >> 7) - flag &= 0x7f - if flag < 84: - nBytes = 1 - elif flag < 120: - nBytes = 2 - elif flag < 124: - nBytes = 3 - else: - nBytes = 4 - assert ((tripletIndex + nBytes) <= nTriplets) - if flag < 10: - dx = 0 - dy = withSign(flag, ((flag & 14) << 7) + triplets[tripletIndex]) - elif flag < 20: - dx = withSign(flag, (((flag - 10) & 14) << 7) + triplets[tripletIndex]) - dy = 0 - elif flag < 84: - b0 = flag - 20 - b1 = triplets[tripletIndex] - dx = withSign(flag, 1 + (b0 & 0x30) + (b1 >> 4)) - dy = withSign(flag >> 1, 1 + ((b0 & 0x0c) << 2) + (b1 & 0x0f)) - elif flag < 120: - b0 = flag - 84 - dx = withSign(flag, 1 + ((b0 // 12) << 8) + triplets[tripletIndex]) - dy = withSign(flag >> 1, - 1 + (((b0 % 12) >> 2) << 8) + triplets[tripletIndex + 1]) - elif flag < 124: - b2 = triplets[tripletIndex + 1] - dx = withSign(flag, (triplets[tripletIndex] << 4) + (b2 >> 4)) - dy = withSign(flag >> 1, - ((b2 & 0x0f) << 8) + triplets[tripletIndex + 2]) - else: - dx = withSign(flag, - (triplets[tripletIndex] << 8) + triplets[tripletIndex + 1]) - dy = withSign(flag >> 1, - (triplets[tripletIndex + 2] << 8) + triplets[tripletIndex + 3]) - tripletIndex += nBytes - x += dx - y += dy - glyph.coordinates[i] = (x, y) - glyph.flags.append(int(onCurve)) - bytesConsumed = tripletIndex - self.glyphStream = self.glyphStream[bytesConsumed:] - - def _encodeGlyph(self, glyphID): - glyphName = self.getGlyphName(glyphID) - glyph = self[glyphName] - self.nContourStream += struct.pack(">h", glyph.numberOfContours) - if glyph.numberOfContours == 0: - return - elif glyph.isComposite(): - self._encodeComponents(glyph) - else: - self._encodeCoordinates(glyph) - self._encodeBBox(glyphID, glyph) - - def _encodeComponents(self, glyph): - lastcomponent = len(glyph.components) - 1 - more = 1 - haveInstructions = 0 - for i in range(len(glyph.components)): - if i == lastcomponent: - haveInstructions = hasattr(glyph, "program") - more = 0 - component = glyph.components[i] - self.compositeStream += component.compile(more, haveInstructions, self) - if haveInstructions: - self._encodeInstructions(glyph) - - def _encodeCoordinates(self, glyph): - lastEndPoint = -1 - for endPoint in glyph.endPtsOfContours: - ptsOfContour = endPoint - lastEndPoint - self.nPointsStream += pack255UShort(ptsOfContour) - lastEndPoint = endPoint - self._encodeTriplets(glyph) - self._encodeInstructions(glyph) - - def _encodeInstructions(self, glyph): - instructions = glyph.program.getBytecode() - self.glyphStream += pack255UShort(len(instructions)) - self.instructionStream += instructions - - def _encodeBBox(self, glyphID, glyph): - assert glyph.numberOfContours != 0, "empty glyph has no bbox" - if not glyph.isComposite(): - # for simple glyphs, compare the encoded bounding box info with the calculated - # values, and if they match omit the bounding box info - currentBBox = glyph.xMin, glyph.yMin, glyph.xMax, glyph.yMax - calculatedBBox = calcIntBounds(glyph.coordinates) - if currentBBox == calculatedBBox: - return - self.bboxBitmap[glyphID >> 3] |= 0x80 >> (glyphID & 7) - self.bboxStream += sstruct.pack(bboxFormat, glyph) - - def _encodeTriplets(self, glyph): - assert len(glyph.coordinates) == len(glyph.flags) - coordinates = glyph.coordinates.copy() - coordinates.absoluteToRelative() - - flags = array.array('B') - triplets = array.array('B') - for i in range(len(coordinates)): - onCurve = glyph.flags[i] & _g_l_y_f.flagOnCurve - x, y = coordinates[i] - absX = abs(x) - absY = abs(y) - onCurveBit = 0 if onCurve else 128 - xSignBit = 0 if (x < 0) else 1 - ySignBit = 0 if (y < 0) else 1 - xySignBits = xSignBit + 2 * ySignBit - - if x == 0 and absY < 1280: - flags.append(onCurveBit + ((absY & 0xf00) >> 7) + ySignBit) - triplets.append(absY & 0xff) - elif y == 0 and absX < 1280: - flags.append(onCurveBit + 10 + ((absX & 0xf00) >> 7) + xSignBit) - triplets.append(absX & 0xff) - elif absX < 65 and absY < 65: - flags.append(onCurveBit + 20 + ((absX - 1) & 0x30) + (((absY - 1) & 0x30) >> 2) + xySignBits) - triplets.append((((absX - 1) & 0xf) << 4) | ((absY - 1) & 0xf)) - elif absX < 769 and absY < 769: - flags.append(onCurveBit + 84 + 12 * (((absX - 1) & 0x300) >> 8) + (((absY - 1) & 0x300) >> 6) + xySignBits) - triplets.append((absX - 1) & 0xff) - triplets.append((absY - 1) & 0xff) - elif absX < 4096 and absY < 4096: - flags.append(onCurveBit + 120 + xySignBits) - triplets.append(absX >> 4) - triplets.append(((absX & 0xf) << 4) | (absY >> 8)) - triplets.append(absY & 0xff) - else: - flags.append(onCurveBit + 124 + xySignBits) - triplets.append(absX >> 8) - triplets.append(absX & 0xff) - triplets.append(absY >> 8) - triplets.append(absY & 0xff) - - self.flagStream += flags.tobytes() - self.glyphStream += triplets.tobytes() + def fromFile(self, file): + pos = file.tell() + data = file.read(woff2DirectoryEntryMaxSize) + left = self.fromString(data) + consumed = len(data) - len(left) + file.seek(pos + consumed) + + def fromString(self, data): + if len(data) < 1: + raise TTLibError("can't read table 'flags': not enough data") + dummy, data = sstruct.unpack2(woff2FlagsFormat, data, self) + if self.flags & 0x3F == 0x3F: + # if bits [0..5] of the flags byte == 63, read a 4-byte arbitrary tag value + if len(data) < woff2UnknownTagSize: + raise TTLibError("can't read table 'tag': not enough data") + dummy, data = sstruct.unpack2(woff2UnknownTagFormat, data, self) + else: + # otherwise, tag is derived from a fixed 'Known Tags' table + self.tag = woff2KnownTags[self.flags & 0x3F] + self.tag = Tag(self.tag) + self.origLength, data = unpackBase128(data) + self.length = self.origLength + if self.transformed: + self.length, data = unpackBase128(data) + if self.tag == "loca" and self.length != 0: + raise TTLibError("the transformLength of the 'loca' table must be 0") + # return left over data + return data + + def toString(self): + data = bytechr(self.flags) + if (self.flags & 0x3F) == 0x3F: + data += struct.pack(">4s", self.tag.tobytes()) + data += packBase128(self.origLength) + if self.transformed: + data += packBase128(self.length) + return data + + @property + def transformVersion(self): + """Return bits 6-7 of table entry's flags, which indicate the preprocessing + transformation version number (between 0 and 3). + """ + return self.flags >> 6 + + @transformVersion.setter + def transformVersion(self, value): + assert 0 <= value <= 3 + self.flags |= value << 6 + + @property + def transformed(self): + """Return True if the table has any transformation, else return False.""" + # For all tables in a font, except for 'glyf' and 'loca', the transformation + # version 0 indicates the null transform (where the original table data is + # passed directly to the Brotli compressor). For 'glyf' and 'loca' tables, + # transformation version 3 indicates the null transform + if self.tag in {"glyf", "loca"}: + return self.transformVersion != 3 + else: + return self.transformVersion != 0 + + @transformed.setter + def transformed(self, booleanValue): + # here we assume that a non-null transform means version 0 for 'glyf' and + # 'loca' and 1 for every other table (e.g. hmtx); but that may change as + # new transformation formats are introduced in the future (if ever). + if self.tag in {"glyf", "loca"}: + self.transformVersion = 3 if not booleanValue else 0 + else: + self.transformVersion = int(booleanValue) + + +class WOFF2LocaTable(getTableClass("loca")): + """Same as parent class. The only difference is that it attempts to preserve + the 'indexFormat' as encoded in the WOFF2 glyf table. + """ + + def __init__(self, tag=None): + self.tableTag = Tag(tag or "loca") + + def compile(self, ttFont): + try: + max_location = max(self.locations) + except AttributeError: + self.set([]) + max_location = 0 + if "glyf" in ttFont and hasattr(ttFont["glyf"], "indexFormat"): + # copile loca using the indexFormat specified in the WOFF2 glyf table + indexFormat = ttFont["glyf"].indexFormat + if indexFormat == 0: + if max_location >= 0x20000: + raise TTLibError("indexFormat is 0 but local offsets > 0x20000") + if not all(l % 2 == 0 for l in self.locations): + raise TTLibError( + "indexFormat is 0 but local offsets not multiples of 2" + ) + locations = array.array("H") + for i in range(len(self.locations)): + locations.append(self.locations[i] // 2) + else: + locations = array.array("I", self.locations) + if sys.byteorder != "big": + locations.byteswap() + data = locations.tobytes() + else: + # use the most compact indexFormat given the current glyph offsets + data = super(WOFF2LocaTable, self).compile(ttFont) + return data + + +class WOFF2GlyfTable(getTableClass("glyf")): + """Decoder/Encoder for WOFF2 'glyf' table transform.""" + + subStreams = ( + "nContourStream", + "nPointsStream", + "flagStream", + "glyphStream", + "compositeStream", + "bboxStream", + "instructionStream", + ) + + def __init__(self, tag=None): + self.tableTag = Tag(tag or "glyf") + + def reconstruct(self, data, ttFont): + """Decompile transformed 'glyf' data.""" + inputDataSize = len(data) + + if inputDataSize < woff2GlyfTableFormatSize: + raise TTLibError("not enough 'glyf' data") + dummy, data = sstruct.unpack2(woff2GlyfTableFormat, data, self) + offset = woff2GlyfTableFormatSize + + for stream in self.subStreams: + size = getattr(self, stream + "Size") + setattr(self, stream, data[:size]) + data = data[size:] + offset += size + + hasOverlapSimpleBitmap = self.optionFlags & woff2OverlapSimpleBitmapFlag + self.overlapSimpleBitmap = None + if hasOverlapSimpleBitmap: + overlapSimpleBitmapSize = (self.numGlyphs + 7) >> 3 + self.overlapSimpleBitmap = array.array("B", data[:overlapSimpleBitmapSize]) + offset += overlapSimpleBitmapSize + + if offset != inputDataSize: + raise TTLibError( + "incorrect size of transformed 'glyf' table: expected %d, received %d bytes" + % (offset, inputDataSize) + ) + + bboxBitmapSize = ((self.numGlyphs + 31) >> 5) << 2 + bboxBitmap = self.bboxStream[:bboxBitmapSize] + self.bboxBitmap = array.array("B", bboxBitmap) + self.bboxStream = self.bboxStream[bboxBitmapSize:] + + self.nContourStream = array.array("h", self.nContourStream) + if sys.byteorder != "big": + self.nContourStream.byteswap() + assert len(self.nContourStream) == self.numGlyphs + + if "head" in ttFont: + ttFont["head"].indexToLocFormat = self.indexFormat + try: + self.glyphOrder = ttFont.getGlyphOrder() + except: + self.glyphOrder = None + if self.glyphOrder is None: + self.glyphOrder = [".notdef"] + self.glyphOrder.extend(["glyph%.5d" % i for i in range(1, self.numGlyphs)]) + else: + if len(self.glyphOrder) != self.numGlyphs: + raise TTLibError( + "incorrect glyphOrder: expected %d glyphs, found %d" + % (len(self.glyphOrder), self.numGlyphs) + ) + + glyphs = self.glyphs = {} + for glyphID, glyphName in enumerate(self.glyphOrder): + glyph = self._decodeGlyph(glyphID) + glyphs[glyphName] = glyph + + def transform(self, ttFont): + """Return transformed 'glyf' data""" + self.numGlyphs = len(self.glyphs) + assert len(self.glyphOrder) == self.numGlyphs + if "maxp" in ttFont: + ttFont["maxp"].numGlyphs = self.numGlyphs + self.indexFormat = ttFont["head"].indexToLocFormat + + for stream in self.subStreams: + setattr(self, stream, b"") + bboxBitmapSize = ((self.numGlyphs + 31) >> 5) << 2 + self.bboxBitmap = array.array("B", [0] * bboxBitmapSize) + + self.overlapSimpleBitmap = array.array("B", [0] * ((self.numGlyphs + 7) >> 3)) + for glyphID in range(self.numGlyphs): + try: + self._encodeGlyph(glyphID) + except NotImplementedError: + return None + hasOverlapSimpleBitmap = any(self.overlapSimpleBitmap) + + self.bboxStream = self.bboxBitmap.tobytes() + self.bboxStream + for stream in self.subStreams: + setattr(self, stream + "Size", len(getattr(self, stream))) + self.version = 0 + self.optionFlags = 0 + if hasOverlapSimpleBitmap: + self.optionFlags |= woff2OverlapSimpleBitmapFlag + data = sstruct.pack(woff2GlyfTableFormat, self) + data += bytesjoin([getattr(self, s) for s in self.subStreams]) + if hasOverlapSimpleBitmap: + data += self.overlapSimpleBitmap.tobytes() + return data + + def _decodeGlyph(self, glyphID): + glyph = getTableModule("glyf").Glyph() + glyph.numberOfContours = self.nContourStream[glyphID] + if glyph.numberOfContours == 0: + return glyph + elif glyph.isComposite(): + self._decodeComponents(glyph) + else: + self._decodeCoordinates(glyph) + self._decodeOverlapSimpleFlag(glyph, glyphID) + self._decodeBBox(glyphID, glyph) + return glyph + + def _decodeComponents(self, glyph): + data = self.compositeStream + glyph.components = [] + more = 1 + haveInstructions = 0 + while more: + component = getTableModule("glyf").GlyphComponent() + more, haveInstr, data = component.decompile(data, self) + haveInstructions = haveInstructions | haveInstr + glyph.components.append(component) + self.compositeStream = data + if haveInstructions: + self._decodeInstructions(glyph) + + def _decodeCoordinates(self, glyph): + data = self.nPointsStream + endPtsOfContours = [] + endPoint = -1 + for i in range(glyph.numberOfContours): + ptsOfContour, data = unpack255UShort(data) + endPoint += ptsOfContour + endPtsOfContours.append(endPoint) + glyph.endPtsOfContours = endPtsOfContours + self.nPointsStream = data + self._decodeTriplets(glyph) + self._decodeInstructions(glyph) + + def _decodeOverlapSimpleFlag(self, glyph, glyphID): + if self.overlapSimpleBitmap is None or glyph.numberOfContours <= 0: + return + byte = glyphID >> 3 + bit = glyphID & 7 + if self.overlapSimpleBitmap[byte] & (0x80 >> bit): + glyph.flags[0] |= _g_l_y_f.flagOverlapSimple + + def _decodeInstructions(self, glyph): + glyphStream = self.glyphStream + instructionStream = self.instructionStream + instructionLength, glyphStream = unpack255UShort(glyphStream) + glyph.program = ttProgram.Program() + glyph.program.fromBytecode(instructionStream[:instructionLength]) + self.glyphStream = glyphStream + self.instructionStream = instructionStream[instructionLength:] + + def _decodeBBox(self, glyphID, glyph): + haveBBox = bool(self.bboxBitmap[glyphID >> 3] & (0x80 >> (glyphID & 7))) + if glyph.isComposite() and not haveBBox: + raise TTLibError("no bbox values for composite glyph %d" % glyphID) + if haveBBox: + dummy, self.bboxStream = sstruct.unpack2(bboxFormat, self.bboxStream, glyph) + else: + glyph.recalcBounds(self) + + def _decodeTriplets(self, glyph): + def withSign(flag, baseval): + assert 0 <= baseval and baseval < 65536, "integer overflow" + return baseval if flag & 1 else -baseval + + nPoints = glyph.endPtsOfContours[-1] + 1 + flagSize = nPoints + if flagSize > len(self.flagStream): + raise TTLibError("not enough 'flagStream' data") + flagsData = self.flagStream[:flagSize] + self.flagStream = self.flagStream[flagSize:] + flags = array.array("B", flagsData) + + triplets = array.array("B", self.glyphStream) + nTriplets = len(triplets) + assert nPoints <= nTriplets + + x = 0 + y = 0 + glyph.coordinates = getTableModule("glyf").GlyphCoordinates.zeros(nPoints) + glyph.flags = array.array("B") + tripletIndex = 0 + for i in range(nPoints): + flag = flags[i] + onCurve = not bool(flag >> 7) + flag &= 0x7F + if flag < 84: + nBytes = 1 + elif flag < 120: + nBytes = 2 + elif flag < 124: + nBytes = 3 + else: + nBytes = 4 + assert (tripletIndex + nBytes) <= nTriplets + if flag < 10: + dx = 0 + dy = withSign(flag, ((flag & 14) << 7) + triplets[tripletIndex]) + elif flag < 20: + dx = withSign(flag, (((flag - 10) & 14) << 7) + triplets[tripletIndex]) + dy = 0 + elif flag < 84: + b0 = flag - 20 + b1 = triplets[tripletIndex] + dx = withSign(flag, 1 + (b0 & 0x30) + (b1 >> 4)) + dy = withSign(flag >> 1, 1 + ((b0 & 0x0C) << 2) + (b1 & 0x0F)) + elif flag < 120: + b0 = flag - 84 + dx = withSign(flag, 1 + ((b0 // 12) << 8) + triplets[tripletIndex]) + dy = withSign( + flag >> 1, 1 + (((b0 % 12) >> 2) << 8) + triplets[tripletIndex + 1] + ) + elif flag < 124: + b2 = triplets[tripletIndex + 1] + dx = withSign(flag, (triplets[tripletIndex] << 4) + (b2 >> 4)) + dy = withSign( + flag >> 1, ((b2 & 0x0F) << 8) + triplets[tripletIndex + 2] + ) + else: + dx = withSign( + flag, (triplets[tripletIndex] << 8) + triplets[tripletIndex + 1] + ) + dy = withSign( + flag >> 1, + (triplets[tripletIndex + 2] << 8) + triplets[tripletIndex + 3], + ) + tripletIndex += nBytes + x += dx + y += dy + glyph.coordinates[i] = (x, y) + glyph.flags.append(int(onCurve)) + bytesConsumed = tripletIndex + self.glyphStream = self.glyphStream[bytesConsumed:] + + def _encodeGlyph(self, glyphID): + glyphName = self.getGlyphName(glyphID) + glyph = self[glyphName] + self.nContourStream += struct.pack(">h", glyph.numberOfContours) + if glyph.numberOfContours == 0: + return + elif glyph.isComposite(): + self._encodeComponents(glyph) + elif glyph.isVarComposite(): + raise NotImplementedError + else: + self._encodeCoordinates(glyph) + self._encodeOverlapSimpleFlag(glyph, glyphID) + self._encodeBBox(glyphID, glyph) + + def _encodeComponents(self, glyph): + lastcomponent = len(glyph.components) - 1 + more = 1 + haveInstructions = 0 + for i in range(len(glyph.components)): + if i == lastcomponent: + haveInstructions = hasattr(glyph, "program") + more = 0 + component = glyph.components[i] + self.compositeStream += component.compile(more, haveInstructions, self) + if haveInstructions: + self._encodeInstructions(glyph) + + def _encodeCoordinates(self, glyph): + lastEndPoint = -1 + if _g_l_y_f.flagCubic in glyph.flags: + raise NotImplementedError + for endPoint in glyph.endPtsOfContours: + ptsOfContour = endPoint - lastEndPoint + self.nPointsStream += pack255UShort(ptsOfContour) + lastEndPoint = endPoint + self._encodeTriplets(glyph) + self._encodeInstructions(glyph) + + def _encodeOverlapSimpleFlag(self, glyph, glyphID): + if glyph.numberOfContours <= 0: + return + if glyph.flags[0] & _g_l_y_f.flagOverlapSimple: + byte = glyphID >> 3 + bit = glyphID & 7 + self.overlapSimpleBitmap[byte] |= 0x80 >> bit + + def _encodeInstructions(self, glyph): + instructions = glyph.program.getBytecode() + self.glyphStream += pack255UShort(len(instructions)) + self.instructionStream += instructions + + def _encodeBBox(self, glyphID, glyph): + assert glyph.numberOfContours != 0, "empty glyph has no bbox" + if not glyph.isComposite(): + # for simple glyphs, compare the encoded bounding box info with the calculated + # values, and if they match omit the bounding box info + currentBBox = glyph.xMin, glyph.yMin, glyph.xMax, glyph.yMax + calculatedBBox = calcIntBounds(glyph.coordinates) + if currentBBox == calculatedBBox: + return + self.bboxBitmap[glyphID >> 3] |= 0x80 >> (glyphID & 7) + self.bboxStream += sstruct.pack(bboxFormat, glyph) + + def _encodeTriplets(self, glyph): + assert len(glyph.coordinates) == len(glyph.flags) + coordinates = glyph.coordinates.copy() + coordinates.absoluteToRelative() + + flags = array.array("B") + triplets = array.array("B") + for i in range(len(coordinates)): + onCurve = glyph.flags[i] & _g_l_y_f.flagOnCurve + x, y = coordinates[i] + absX = abs(x) + absY = abs(y) + onCurveBit = 0 if onCurve else 128 + xSignBit = 0 if (x < 0) else 1 + ySignBit = 0 if (y < 0) else 1 + xySignBits = xSignBit + 2 * ySignBit + + if x == 0 and absY < 1280: + flags.append(onCurveBit + ((absY & 0xF00) >> 7) + ySignBit) + triplets.append(absY & 0xFF) + elif y == 0 and absX < 1280: + flags.append(onCurveBit + 10 + ((absX & 0xF00) >> 7) + xSignBit) + triplets.append(absX & 0xFF) + elif absX < 65 and absY < 65: + flags.append( + onCurveBit + + 20 + + ((absX - 1) & 0x30) + + (((absY - 1) & 0x30) >> 2) + + xySignBits + ) + triplets.append((((absX - 1) & 0xF) << 4) | ((absY - 1) & 0xF)) + elif absX < 769 and absY < 769: + flags.append( + onCurveBit + + 84 + + 12 * (((absX - 1) & 0x300) >> 8) + + (((absY - 1) & 0x300) >> 6) + + xySignBits + ) + triplets.append((absX - 1) & 0xFF) + triplets.append((absY - 1) & 0xFF) + elif absX < 4096 and absY < 4096: + flags.append(onCurveBit + 120 + xySignBits) + triplets.append(absX >> 4) + triplets.append(((absX & 0xF) << 4) | (absY >> 8)) + triplets.append(absY & 0xFF) + else: + flags.append(onCurveBit + 124 + xySignBits) + triplets.append(absX >> 8) + triplets.append(absX & 0xFF) + triplets.append(absY >> 8) + triplets.append(absY & 0xFF) + + self.flagStream += flags.tobytes() + self.glyphStream += triplets.tobytes() class WOFF2HmtxTable(getTableClass("hmtx")): - - def __init__(self, tag=None): - self.tableTag = Tag(tag or 'hmtx') - - def reconstruct(self, data, ttFont): - flags, = struct.unpack(">B", data[:1]) - data = data[1:] - if flags & 0b11111100 != 0: - raise TTLibError("Bits 2-7 of '%s' flags are reserved" % self.tableTag) - - # When bit 0 is _not_ set, the lsb[] array is present - hasLsbArray = flags & 1 == 0 - # When bit 1 is _not_ set, the leftSideBearing[] array is present - hasLeftSideBearingArray = flags & 2 == 0 - if hasLsbArray and hasLeftSideBearingArray: - raise TTLibError( - "either bits 0 or 1 (or both) must set in transformed '%s' flags" - % self.tableTag - ) - - glyfTable = ttFont["glyf"] - headerTable = ttFont["hhea"] - glyphOrder = glyfTable.glyphOrder - numGlyphs = len(glyphOrder) - numberOfHMetrics = min(int(headerTable.numberOfHMetrics), numGlyphs) - - assert len(data) >= 2 * numberOfHMetrics - advanceWidthArray = array.array("H", data[:2 * numberOfHMetrics]) - if sys.byteorder != "big": - advanceWidthArray.byteswap() - data = data[2 * numberOfHMetrics:] - - if hasLsbArray: - assert len(data) >= 2 * numberOfHMetrics - lsbArray = array.array("h", data[:2 * numberOfHMetrics]) - if sys.byteorder != "big": - lsbArray.byteswap() - data = data[2 * numberOfHMetrics:] - else: - # compute (proportional) glyphs' lsb from their xMin - lsbArray = array.array("h") - for i, glyphName in enumerate(glyphOrder): - if i >= numberOfHMetrics: - break - glyph = glyfTable[glyphName] - xMin = getattr(glyph, "xMin", 0) - lsbArray.append(xMin) - - numberOfSideBearings = numGlyphs - numberOfHMetrics - if hasLeftSideBearingArray: - assert len(data) >= 2 * numberOfSideBearings - leftSideBearingArray = array.array("h", data[:2 * numberOfSideBearings]) - if sys.byteorder != "big": - leftSideBearingArray.byteswap() - data = data[2 * numberOfSideBearings:] - else: - # compute (monospaced) glyphs' leftSideBearing from their xMin - leftSideBearingArray = array.array("h") - for i, glyphName in enumerate(glyphOrder): - if i < numberOfHMetrics: - continue - glyph = glyfTable[glyphName] - xMin = getattr(glyph, "xMin", 0) - leftSideBearingArray.append(xMin) - - if data: - raise TTLibError("too much '%s' table data" % self.tableTag) - - self.metrics = {} - for i in range(numberOfHMetrics): - glyphName = glyphOrder[i] - advanceWidth, lsb = advanceWidthArray[i], lsbArray[i] - self.metrics[glyphName] = (advanceWidth, lsb) - lastAdvance = advanceWidthArray[-1] - for i in range(numberOfSideBearings): - glyphName = glyphOrder[i + numberOfHMetrics] - self.metrics[glyphName] = (lastAdvance, leftSideBearingArray[i]) - - def transform(self, ttFont): - glyphOrder = ttFont.getGlyphOrder() - glyf = ttFont["glyf"] - hhea = ttFont["hhea"] - numberOfHMetrics = hhea.numberOfHMetrics - - # check if any of the proportional glyphs has left sidebearings that - # differ from their xMin bounding box values. - hasLsbArray = False - for i in range(numberOfHMetrics): - glyphName = glyphOrder[i] - lsb = self.metrics[glyphName][1] - if lsb != getattr(glyf[glyphName], "xMin", 0): - hasLsbArray = True - break - - # do the same for the monospaced glyphs (if any) at the end of hmtx table - hasLeftSideBearingArray = False - for i in range(numberOfHMetrics, len(glyphOrder)): - glyphName = glyphOrder[i] - lsb = self.metrics[glyphName][1] - if lsb != getattr(glyf[glyphName], "xMin", 0): - hasLeftSideBearingArray = True - break - - # if we need to encode both sidebearings arrays, then no transformation is - # applicable, and we must use the untransformed hmtx data - if hasLsbArray and hasLeftSideBearingArray: - return - - # set bit 0 and 1 when the respective arrays are _not_ present - flags = 0 - if not hasLsbArray: - flags |= 1 << 0 - if not hasLeftSideBearingArray: - flags |= 1 << 1 - - data = struct.pack(">B", flags) - - advanceWidthArray = array.array( - "H", - [ - self.metrics[glyphName][0] - for i, glyphName in enumerate(glyphOrder) - if i < numberOfHMetrics - ] - ) - if sys.byteorder != "big": - advanceWidthArray.byteswap() - data += advanceWidthArray.tobytes() - - if hasLsbArray: - lsbArray = array.array( - "h", - [ - self.metrics[glyphName][1] - for i, glyphName in enumerate(glyphOrder) - if i < numberOfHMetrics - ] - ) - if sys.byteorder != "big": - lsbArray.byteswap() - data += lsbArray.tobytes() - - if hasLeftSideBearingArray: - leftSideBearingArray = array.array( - "h", - [ - self.metrics[glyphOrder[i]][1] - for i in range(numberOfHMetrics, len(glyphOrder)) - ] - ) - if sys.byteorder != "big": - leftSideBearingArray.byteswap() - data += leftSideBearingArray.tobytes() - - return data + def __init__(self, tag=None): + self.tableTag = Tag(tag or "hmtx") + + def reconstruct(self, data, ttFont): + (flags,) = struct.unpack(">B", data[:1]) + data = data[1:] + if flags & 0b11111100 != 0: + raise TTLibError("Bits 2-7 of '%s' flags are reserved" % self.tableTag) + + # When bit 0 is _not_ set, the lsb[] array is present + hasLsbArray = flags & 1 == 0 + # When bit 1 is _not_ set, the leftSideBearing[] array is present + hasLeftSideBearingArray = flags & 2 == 0 + if hasLsbArray and hasLeftSideBearingArray: + raise TTLibError( + "either bits 0 or 1 (or both) must set in transformed '%s' flags" + % self.tableTag + ) + + glyfTable = ttFont["glyf"] + headerTable = ttFont["hhea"] + glyphOrder = glyfTable.glyphOrder + numGlyphs = len(glyphOrder) + numberOfHMetrics = min(int(headerTable.numberOfHMetrics), numGlyphs) + + assert len(data) >= 2 * numberOfHMetrics + advanceWidthArray = array.array("H", data[: 2 * numberOfHMetrics]) + if sys.byteorder != "big": + advanceWidthArray.byteswap() + data = data[2 * numberOfHMetrics :] + + if hasLsbArray: + assert len(data) >= 2 * numberOfHMetrics + lsbArray = array.array("h", data[: 2 * numberOfHMetrics]) + if sys.byteorder != "big": + lsbArray.byteswap() + data = data[2 * numberOfHMetrics :] + else: + # compute (proportional) glyphs' lsb from their xMin + lsbArray = array.array("h") + for i, glyphName in enumerate(glyphOrder): + if i >= numberOfHMetrics: + break + glyph = glyfTable[glyphName] + xMin = getattr(glyph, "xMin", 0) + lsbArray.append(xMin) + + numberOfSideBearings = numGlyphs - numberOfHMetrics + if hasLeftSideBearingArray: + assert len(data) >= 2 * numberOfSideBearings + leftSideBearingArray = array.array("h", data[: 2 * numberOfSideBearings]) + if sys.byteorder != "big": + leftSideBearingArray.byteswap() + data = data[2 * numberOfSideBearings :] + else: + # compute (monospaced) glyphs' leftSideBearing from their xMin + leftSideBearingArray = array.array("h") + for i, glyphName in enumerate(glyphOrder): + if i < numberOfHMetrics: + continue + glyph = glyfTable[glyphName] + xMin = getattr(glyph, "xMin", 0) + leftSideBearingArray.append(xMin) + + if data: + raise TTLibError("too much '%s' table data" % self.tableTag) + + self.metrics = {} + for i in range(numberOfHMetrics): + glyphName = glyphOrder[i] + advanceWidth, lsb = advanceWidthArray[i], lsbArray[i] + self.metrics[glyphName] = (advanceWidth, lsb) + lastAdvance = advanceWidthArray[-1] + for i in range(numberOfSideBearings): + glyphName = glyphOrder[i + numberOfHMetrics] + self.metrics[glyphName] = (lastAdvance, leftSideBearingArray[i]) + + def transform(self, ttFont): + glyphOrder = ttFont.getGlyphOrder() + glyf = ttFont["glyf"] + hhea = ttFont["hhea"] + numberOfHMetrics = hhea.numberOfHMetrics + + # check if any of the proportional glyphs has left sidebearings that + # differ from their xMin bounding box values. + hasLsbArray = False + for i in range(numberOfHMetrics): + glyphName = glyphOrder[i] + lsb = self.metrics[glyphName][1] + if lsb != getattr(glyf[glyphName], "xMin", 0): + hasLsbArray = True + break + + # do the same for the monospaced glyphs (if any) at the end of hmtx table + hasLeftSideBearingArray = False + for i in range(numberOfHMetrics, len(glyphOrder)): + glyphName = glyphOrder[i] + lsb = self.metrics[glyphName][1] + if lsb != getattr(glyf[glyphName], "xMin", 0): + hasLeftSideBearingArray = True + break + + # if we need to encode both sidebearings arrays, then no transformation is + # applicable, and we must use the untransformed hmtx data + if hasLsbArray and hasLeftSideBearingArray: + return + + # set bit 0 and 1 when the respective arrays are _not_ present + flags = 0 + if not hasLsbArray: + flags |= 1 << 0 + if not hasLeftSideBearingArray: + flags |= 1 << 1 + + data = struct.pack(">B", flags) + + advanceWidthArray = array.array( + "H", + [ + self.metrics[glyphName][0] + for i, glyphName in enumerate(glyphOrder) + if i < numberOfHMetrics + ], + ) + if sys.byteorder != "big": + advanceWidthArray.byteswap() + data += advanceWidthArray.tobytes() + + if hasLsbArray: + lsbArray = array.array( + "h", + [ + self.metrics[glyphName][1] + for i, glyphName in enumerate(glyphOrder) + if i < numberOfHMetrics + ], + ) + if sys.byteorder != "big": + lsbArray.byteswap() + data += lsbArray.tobytes() + + if hasLeftSideBearingArray: + leftSideBearingArray = array.array( + "h", + [ + self.metrics[glyphOrder[i]][1] + for i in range(numberOfHMetrics, len(glyphOrder)) + ], + ) + if sys.byteorder != "big": + leftSideBearingArray.byteswap() + data += leftSideBearingArray.tobytes() + + return data class WOFF2FlavorData(WOFFFlavorData): - - Flavor = 'woff2' - - def __init__(self, reader=None, data=None, transformedTables=None): - """Data class that holds the WOFF2 header major/minor version, any - metadata or private data (as bytes strings), and the set of - table tags that have transformations applied (if reader is not None), - or will have once the WOFF2 font is compiled. - - Args: - reader: an SFNTReader (or subclass) object to read flavor data from. - data: another WOFFFlavorData object to initialise data from. - transformedTables: set of strings containing table tags to be transformed. - - Raises: - ImportError if the brotli module is not installed. - - NOTE: The 'reader' argument, on the one hand, and the 'data' and - 'transformedTables' arguments, on the other hand, are mutually exclusive. - """ - if not haveBrotli: - raise ImportError("No module named brotli") - - if reader is not None: - if data is not None: - raise TypeError( - "'reader' and 'data' arguments are mutually exclusive" - ) - if transformedTables is not None: - raise TypeError( - "'reader' and 'transformedTables' arguments are mutually exclusive" - ) - - if transformedTables is not None and ( - "glyf" in transformedTables and "loca" not in transformedTables - or "loca" in transformedTables and "glyf" not in transformedTables - ): - raise ValueError( - "'glyf' and 'loca' must be transformed (or not) together" - ) - super(WOFF2FlavorData, self).__init__(reader=reader) - if reader: - transformedTables = [ - tag - for tag, entry in reader.tables.items() - if entry.transformed - ] - elif data: - self.majorVersion = data.majorVersion - self.majorVersion = data.minorVersion - self.metaData = data.metaData - self.privData = data.privData - if transformedTables is None and hasattr(data, "transformedTables"): - transformedTables = data.transformedTables - - if transformedTables is None: - transformedTables = woff2TransformedTableTags - - self.transformedTables = set(transformedTables) - - def _decompress(self, rawData): - return brotli.decompress(rawData) + Flavor = "woff2" + + def __init__(self, reader=None, data=None, transformedTables=None): + """Data class that holds the WOFF2 header major/minor version, any + metadata or private data (as bytes strings), and the set of + table tags that have transformations applied (if reader is not None), + or will have once the WOFF2 font is compiled. + + Args: + reader: an SFNTReader (or subclass) object to read flavor data from. + data: another WOFFFlavorData object to initialise data from. + transformedTables: set of strings containing table tags to be transformed. + + Raises: + ImportError if the brotli module is not installed. + + NOTE: The 'reader' argument, on the one hand, and the 'data' and + 'transformedTables' arguments, on the other hand, are mutually exclusive. + """ + if not haveBrotli: + raise ImportError("No module named brotli") + + if reader is not None: + if data is not None: + raise TypeError("'reader' and 'data' arguments are mutually exclusive") + if transformedTables is not None: + raise TypeError( + "'reader' and 'transformedTables' arguments are mutually exclusive" + ) + + if transformedTables is not None and ( + "glyf" in transformedTables + and "loca" not in transformedTables + or "loca" in transformedTables + and "glyf" not in transformedTables + ): + raise ValueError("'glyf' and 'loca' must be transformed (or not) together") + super(WOFF2FlavorData, self).__init__(reader=reader) + if reader: + transformedTables = [ + tag for tag, entry in reader.tables.items() if entry.transformed + ] + elif data: + self.majorVersion = data.majorVersion + self.majorVersion = data.minorVersion + self.metaData = data.metaData + self.privData = data.privData + if transformedTables is None and hasattr(data, "transformedTables"): + transformedTables = data.transformedTables + + if transformedTables is None: + transformedTables = woff2TransformedTableTags + + self.transformedTables = set(transformedTables) + + def _decompress(self, rawData): + return brotli.decompress(rawData) def unpackBase128(data): - r""" Read one to five bytes from UIntBase128-encoded input string, and return - a tuple containing the decoded integer plus any leftover data. - - >>> unpackBase128(b'\x3f\x00\x00') == (63, b"\x00\x00") - True - >>> unpackBase128(b'\x8f\xff\xff\xff\x7f')[0] == 4294967295 - True - >>> unpackBase128(b'\x80\x80\x3f') # doctest: +IGNORE_EXCEPTION_DETAIL - Traceback (most recent call last): - File "<stdin>", line 1, in ? - TTLibError: UIntBase128 value must not start with leading zeros - >>> unpackBase128(b'\x8f\xff\xff\xff\xff\x7f')[0] # doctest: +IGNORE_EXCEPTION_DETAIL - Traceback (most recent call last): - File "<stdin>", line 1, in ? - TTLibError: UIntBase128-encoded sequence is longer than 5 bytes - >>> unpackBase128(b'\x90\x80\x80\x80\x00')[0] # doctest: +IGNORE_EXCEPTION_DETAIL - Traceback (most recent call last): - File "<stdin>", line 1, in ? - TTLibError: UIntBase128 value exceeds 2**32-1 - """ - if len(data) == 0: - raise TTLibError('not enough data to unpack UIntBase128') - result = 0 - if byteord(data[0]) == 0x80: - # font must be rejected if UIntBase128 value starts with 0x80 - raise TTLibError('UIntBase128 value must not start with leading zeros') - for i in range(woff2Base128MaxSize): - if len(data) == 0: - raise TTLibError('not enough data to unpack UIntBase128') - code = byteord(data[0]) - data = data[1:] - # if any of the top seven bits are set then we're about to overflow - if result & 0xFE000000: - raise TTLibError('UIntBase128 value exceeds 2**32-1') - # set current value = old value times 128 bitwise-or (byte bitwise-and 127) - result = (result << 7) | (code & 0x7f) - # repeat until the most significant bit of byte is false - if (code & 0x80) == 0: - # return result plus left over data - return result, data - # make sure not to exceed the size bound - raise TTLibError('UIntBase128-encoded sequence is longer than 5 bytes') + r"""Read one to five bytes from UIntBase128-encoded input string, and return + a tuple containing the decoded integer plus any leftover data. + + >>> unpackBase128(b'\x3f\x00\x00') == (63, b"\x00\x00") + True + >>> unpackBase128(b'\x8f\xff\xff\xff\x7f')[0] == 4294967295 + True + >>> unpackBase128(b'\x80\x80\x3f') # doctest: +IGNORE_EXCEPTION_DETAIL + Traceback (most recent call last): + File "<stdin>", line 1, in ? + TTLibError: UIntBase128 value must not start with leading zeros + >>> unpackBase128(b'\x8f\xff\xff\xff\xff\x7f')[0] # doctest: +IGNORE_EXCEPTION_DETAIL + Traceback (most recent call last): + File "<stdin>", line 1, in ? + TTLibError: UIntBase128-encoded sequence is longer than 5 bytes + >>> unpackBase128(b'\x90\x80\x80\x80\x00')[0] # doctest: +IGNORE_EXCEPTION_DETAIL + Traceback (most recent call last): + File "<stdin>", line 1, in ? + TTLibError: UIntBase128 value exceeds 2**32-1 + """ + if len(data) == 0: + raise TTLibError("not enough data to unpack UIntBase128") + result = 0 + if byteord(data[0]) == 0x80: + # font must be rejected if UIntBase128 value starts with 0x80 + raise TTLibError("UIntBase128 value must not start with leading zeros") + for i in range(woff2Base128MaxSize): + if len(data) == 0: + raise TTLibError("not enough data to unpack UIntBase128") + code = byteord(data[0]) + data = data[1:] + # if any of the top seven bits are set then we're about to overflow + if result & 0xFE000000: + raise TTLibError("UIntBase128 value exceeds 2**32-1") + # set current value = old value times 128 bitwise-or (byte bitwise-and 127) + result = (result << 7) | (code & 0x7F) + # repeat until the most significant bit of byte is false + if (code & 0x80) == 0: + # return result plus left over data + return result, data + # make sure not to exceed the size bound + raise TTLibError("UIntBase128-encoded sequence is longer than 5 bytes") def base128Size(n): - """ Return the length in bytes of a UIntBase128-encoded sequence with value n. - - >>> base128Size(0) - 1 - >>> base128Size(24567) - 3 - >>> base128Size(2**32-1) - 5 - """ - assert n >= 0 - size = 1 - while n >= 128: - size += 1 - n >>= 7 - return size + """Return the length in bytes of a UIntBase128-encoded sequence with value n. + + >>> base128Size(0) + 1 + >>> base128Size(24567) + 3 + >>> base128Size(2**32-1) + 5 + """ + assert n >= 0 + size = 1 + while n >= 128: + size += 1 + n >>= 7 + return size def packBase128(n): - r""" Encode unsigned integer in range 0 to 2**32-1 (inclusive) to a string of - bytes using UIntBase128 variable-length encoding. Produce the shortest possible - encoding. - - >>> packBase128(63) == b"\x3f" - True - >>> packBase128(2**32-1) == b'\x8f\xff\xff\xff\x7f' - True - """ - if n < 0 or n >= 2**32: - raise TTLibError( - "UIntBase128 format requires 0 <= integer <= 2**32-1") - data = b'' - size = base128Size(n) - for i in range(size): - b = (n >> (7 * (size - i - 1))) & 0x7f - if i < size - 1: - b |= 0x80 - data += struct.pack('B', b) - return data + r"""Encode unsigned integer in range 0 to 2**32-1 (inclusive) to a string of + bytes using UIntBase128 variable-length encoding. Produce the shortest possible + encoding. + + >>> packBase128(63) == b"\x3f" + True + >>> packBase128(2**32-1) == b'\x8f\xff\xff\xff\x7f' + True + """ + if n < 0 or n >= 2**32: + raise TTLibError("UIntBase128 format requires 0 <= integer <= 2**32-1") + data = b"" + size = base128Size(n) + for i in range(size): + b = (n >> (7 * (size - i - 1))) & 0x7F + if i < size - 1: + b |= 0x80 + data += struct.pack("B", b) + return data def unpack255UShort(data): - """ Read one to three bytes from 255UInt16-encoded input string, and return a - tuple containing the decoded integer plus any leftover data. - - >>> unpack255UShort(bytechr(252))[0] - 252 - - Note that some numbers (e.g. 506) can have multiple encodings: - >>> unpack255UShort(struct.pack("BB", 254, 0))[0] - 506 - >>> unpack255UShort(struct.pack("BB", 255, 253))[0] - 506 - >>> unpack255UShort(struct.pack("BBB", 253, 1, 250))[0] - 506 - """ - code = byteord(data[:1]) - data = data[1:] - if code == 253: - # read two more bytes as an unsigned short - if len(data) < 2: - raise TTLibError('not enough data to unpack 255UInt16') - result, = struct.unpack(">H", data[:2]) - data = data[2:] - elif code == 254: - # read another byte, plus 253 * 2 - if len(data) == 0: - raise TTLibError('not enough data to unpack 255UInt16') - result = byteord(data[:1]) - result += 506 - data = data[1:] - elif code == 255: - # read another byte, plus 253 - if len(data) == 0: - raise TTLibError('not enough data to unpack 255UInt16') - result = byteord(data[:1]) - result += 253 - data = data[1:] - else: - # leave as is if lower than 253 - result = code - # return result plus left over data - return result, data + """Read one to three bytes from 255UInt16-encoded input string, and return a + tuple containing the decoded integer plus any leftover data. + + >>> unpack255UShort(bytechr(252))[0] + 252 + + Note that some numbers (e.g. 506) can have multiple encodings: + >>> unpack255UShort(struct.pack("BB", 254, 0))[0] + 506 + >>> unpack255UShort(struct.pack("BB", 255, 253))[0] + 506 + >>> unpack255UShort(struct.pack("BBB", 253, 1, 250))[0] + 506 + """ + code = byteord(data[:1]) + data = data[1:] + if code == 253: + # read two more bytes as an unsigned short + if len(data) < 2: + raise TTLibError("not enough data to unpack 255UInt16") + (result,) = struct.unpack(">H", data[:2]) + data = data[2:] + elif code == 254: + # read another byte, plus 253 * 2 + if len(data) == 0: + raise TTLibError("not enough data to unpack 255UInt16") + result = byteord(data[:1]) + result += 506 + data = data[1:] + elif code == 255: + # read another byte, plus 253 + if len(data) == 0: + raise TTLibError("not enough data to unpack 255UInt16") + result = byteord(data[:1]) + result += 253 + data = data[1:] + else: + # leave as is if lower than 253 + result = code + # return result plus left over data + return result, data def pack255UShort(value): - r""" Encode unsigned integer in range 0 to 65535 (inclusive) to a bytestring - using 255UInt16 variable-length encoding. - - >>> pack255UShort(252) == b'\xfc' - True - >>> pack255UShort(506) == b'\xfe\x00' - True - >>> pack255UShort(762) == b'\xfd\x02\xfa' - True - """ - if value < 0 or value > 0xFFFF: - raise TTLibError( - "255UInt16 format requires 0 <= integer <= 65535") - if value < 253: - return struct.pack(">B", value) - elif value < 506: - return struct.pack(">BB", 255, value - 253) - elif value < 762: - return struct.pack(">BB", 254, value - 506) - else: - return struct.pack(">BH", 253, value) + r"""Encode unsigned integer in range 0 to 65535 (inclusive) to a bytestring + using 255UInt16 variable-length encoding. + + >>> pack255UShort(252) == b'\xfc' + True + >>> pack255UShort(506) == b'\xfe\x00' + True + >>> pack255UShort(762) == b'\xfd\x02\xfa' + True + """ + if value < 0 or value > 0xFFFF: + raise TTLibError("255UInt16 format requires 0 <= integer <= 65535") + if value < 253: + return struct.pack(">B", value) + elif value < 506: + return struct.pack(">BB", 255, value - 253) + elif value < 762: + return struct.pack(">BB", 254, value - 506) + else: + return struct.pack(">BH", 253, value) def compress(input_file, output_file, transform_tables=None): - """Compress OpenType font to WOFF2. + """Compress OpenType font to WOFF2. - Args: - input_file: a file path, file or file-like object (open in binary mode) - containing an OpenType font (either CFF- or TrueType-flavored). - output_file: a file path, file or file-like object where to save the - compressed WOFF2 font. - transform_tables: Optional[Iterable[str]]: a set of table tags for which - to enable preprocessing transformations. By default, only 'glyf' - and 'loca' tables are transformed. An empty set means disable all - transformations. - """ - log.info("Processing %s => %s" % (input_file, output_file)) + Args: + input_file: a file path, file or file-like object (open in binary mode) + containing an OpenType font (either CFF- or TrueType-flavored). + output_file: a file path, file or file-like object where to save the + compressed WOFF2 font. + transform_tables: Optional[Iterable[str]]: a set of table tags for which + to enable preprocessing transformations. By default, only 'glyf' + and 'loca' tables are transformed. An empty set means disable all + transformations. + """ + log.info("Processing %s => %s" % (input_file, output_file)) - font = TTFont(input_file, recalcBBoxes=False, recalcTimestamp=False) - font.flavor = "woff2" + font = TTFont(input_file, recalcBBoxes=False, recalcTimestamp=False) + font.flavor = "woff2" - if transform_tables is not None: - font.flavorData = WOFF2FlavorData( - data=font.flavorData, transformedTables=transform_tables - ) + if transform_tables is not None: + font.flavorData = WOFF2FlavorData( + data=font.flavorData, transformedTables=transform_tables + ) - font.save(output_file, reorderTables=False) + font.save(output_file, reorderTables=False) def decompress(input_file, output_file): - """Decompress WOFF2 font to OpenType font. + """Decompress WOFF2 font to OpenType font. - Args: - input_file: a file path, file or file-like object (open in binary mode) - containing a compressed WOFF2 font. - output_file: a file path, file or file-like object where to save the - decompressed OpenType font. - """ - log.info("Processing %s => %s" % (input_file, output_file)) + Args: + input_file: a file path, file or file-like object (open in binary mode) + containing a compressed WOFF2 font. + output_file: a file path, file or file-like object where to save the + decompressed OpenType font. + """ + log.info("Processing %s => %s" % (input_file, output_file)) - font = TTFont(input_file, recalcBBoxes=False, recalcTimestamp=False) - font.flavor = None - font.flavorData = None - font.save(output_file, reorderTables=True) + font = TTFont(input_file, recalcBBoxes=False, recalcTimestamp=False) + font.flavor = None + font.flavorData = None + font.save(output_file, reorderTables=True) def main(args=None): - """Compress and decompress WOFF2 fonts""" - import argparse - from fontTools import configLogger - from fontTools.ttx import makeOutputFileName - - class _HelpAction(argparse._HelpAction): - - def __call__(self, parser, namespace, values, option_string=None): - subparsers_actions = [ - action for action in parser._actions - if isinstance(action, argparse._SubParsersAction)] - for subparsers_action in subparsers_actions: - for choice, subparser in subparsers_action.choices.items(): - print(subparser.format_help()) - parser.exit() - - class _NoGlyfTransformAction(argparse.Action): - def __call__(self, parser, namespace, values, option_string=None): - namespace.transform_tables.difference_update({"glyf", "loca"}) - - class _HmtxTransformAction(argparse.Action): - def __call__(self, parser, namespace, values, option_string=None): - namespace.transform_tables.add("hmtx") - - parser = argparse.ArgumentParser( - prog="fonttools ttLib.woff2", - description=main.__doc__, - add_help = False - ) - - parser.add_argument('-h', '--help', action=_HelpAction, - help='show this help message and exit') - - parser_group = parser.add_subparsers(title="sub-commands") - parser_compress = parser_group.add_parser("compress", - description = "Compress a TTF or OTF font to WOFF2") - parser_decompress = parser_group.add_parser("decompress", - description = "Decompress a WOFF2 font to OTF") - - for subparser in (parser_compress, parser_decompress): - group = subparser.add_mutually_exclusive_group(required=False) - group.add_argument( - "-v", - "--verbose", - action="store_true", - help="print more messages to console", - ) - group.add_argument( - "-q", - "--quiet", - action="store_true", - help="do not print messages to console", - ) - - parser_compress.add_argument( - "input_file", - metavar="INPUT", - help="the input OpenType font (.ttf or .otf)", - ) - parser_decompress.add_argument( - "input_file", - metavar="INPUT", - help="the input WOFF2 font", - ) - - parser_compress.add_argument( - "-o", - "--output-file", - metavar="OUTPUT", - help="the output WOFF2 font", - ) - parser_decompress.add_argument( - "-o", - "--output-file", - metavar="OUTPUT", - help="the output OpenType font", - ) - - transform_group = parser_compress.add_argument_group() - transform_group.add_argument( - "--no-glyf-transform", - dest="transform_tables", - nargs=0, - action=_NoGlyfTransformAction, - help="Do not transform glyf (and loca) tables", - ) - transform_group.add_argument( - "--hmtx-transform", - dest="transform_tables", - nargs=0, - action=_HmtxTransformAction, - help="Enable optional transformation for 'hmtx' table", - ) - - parser_compress.set_defaults( - subcommand=compress, - transform_tables={"glyf", "loca"}, - ) - parser_decompress.set_defaults(subcommand=decompress) - - options = vars(parser.parse_args(args)) - - subcommand = options.pop("subcommand", None) - if not subcommand: - parser.print_help() - return - - quiet = options.pop("quiet") - verbose = options.pop("verbose") - configLogger( - level=("ERROR" if quiet else "DEBUG" if verbose else "INFO"), - ) - - if not options["output_file"]: - if subcommand is compress: - extension = ".woff2" - elif subcommand is decompress: - # choose .ttf/.otf file extension depending on sfntVersion - with open(options["input_file"], "rb") as f: - f.seek(4) # skip 'wOF2' signature - sfntVersion = f.read(4) - assert len(sfntVersion) == 4, "not enough data" - extension = ".otf" if sfntVersion == b"OTTO" else ".ttf" - else: - raise AssertionError(subcommand) - options["output_file"] = makeOutputFileName( - options["input_file"], outputDir=None, extension=extension - ) - - try: - subcommand(**options) - except TTLibError as e: - parser.error(e) + """Compress and decompress WOFF2 fonts""" + import argparse + from fontTools import configLogger + from fontTools.ttx import makeOutputFileName + + class _HelpAction(argparse._HelpAction): + def __call__(self, parser, namespace, values, option_string=None): + subparsers_actions = [ + action + for action in parser._actions + if isinstance(action, argparse._SubParsersAction) + ] + for subparsers_action in subparsers_actions: + for choice, subparser in subparsers_action.choices.items(): + print(subparser.format_help()) + parser.exit() + + class _NoGlyfTransformAction(argparse.Action): + def __call__(self, parser, namespace, values, option_string=None): + namespace.transform_tables.difference_update({"glyf", "loca"}) + + class _HmtxTransformAction(argparse.Action): + def __call__(self, parser, namespace, values, option_string=None): + namespace.transform_tables.add("hmtx") + + parser = argparse.ArgumentParser( + prog="fonttools ttLib.woff2", description=main.__doc__, add_help=False + ) + + parser.add_argument( + "-h", "--help", action=_HelpAction, help="show this help message and exit" + ) + + parser_group = parser.add_subparsers(title="sub-commands") + parser_compress = parser_group.add_parser( + "compress", description="Compress a TTF or OTF font to WOFF2" + ) + parser_decompress = parser_group.add_parser( + "decompress", description="Decompress a WOFF2 font to OTF" + ) + + for subparser in (parser_compress, parser_decompress): + group = subparser.add_mutually_exclusive_group(required=False) + group.add_argument( + "-v", + "--verbose", + action="store_true", + help="print more messages to console", + ) + group.add_argument( + "-q", + "--quiet", + action="store_true", + help="do not print messages to console", + ) + + parser_compress.add_argument( + "input_file", + metavar="INPUT", + help="the input OpenType font (.ttf or .otf)", + ) + parser_decompress.add_argument( + "input_file", + metavar="INPUT", + help="the input WOFF2 font", + ) + + parser_compress.add_argument( + "-o", + "--output-file", + metavar="OUTPUT", + help="the output WOFF2 font", + ) + parser_decompress.add_argument( + "-o", + "--output-file", + metavar="OUTPUT", + help="the output OpenType font", + ) + + transform_group = parser_compress.add_argument_group() + transform_group.add_argument( + "--no-glyf-transform", + dest="transform_tables", + nargs=0, + action=_NoGlyfTransformAction, + help="Do not transform glyf (and loca) tables", + ) + transform_group.add_argument( + "--hmtx-transform", + dest="transform_tables", + nargs=0, + action=_HmtxTransformAction, + help="Enable optional transformation for 'hmtx' table", + ) + + parser_compress.set_defaults( + subcommand=compress, + transform_tables={"glyf", "loca"}, + ) + parser_decompress.set_defaults(subcommand=decompress) + + options = vars(parser.parse_args(args)) + + subcommand = options.pop("subcommand", None) + if not subcommand: + parser.print_help() + return + + quiet = options.pop("quiet") + verbose = options.pop("verbose") + configLogger( + level=("ERROR" if quiet else "DEBUG" if verbose else "INFO"), + ) + + if not options["output_file"]: + if subcommand is compress: + extension = ".woff2" + elif subcommand is decompress: + # choose .ttf/.otf file extension depending on sfntVersion + with open(options["input_file"], "rb") as f: + f.seek(4) # skip 'wOF2' signature + sfntVersion = f.read(4) + assert len(sfntVersion) == 4, "not enough data" + extension = ".otf" if sfntVersion == b"OTTO" else ".ttf" + else: + raise AssertionError(subcommand) + options["output_file"] = makeOutputFileName( + options["input_file"], outputDir=None, extension=extension + ) + + try: + subcommand(**options) + except TTLibError as e: + parser.error(e) if __name__ == "__main__": - sys.exit(main()) + sys.exit(main()) |