xref: /aosp_15_r20/external/fonttools/Lib/fontTools/ttLib/woff2.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
1from io import BytesIO
2import sys
3import array
4import struct
5from collections import OrderedDict
6from fontTools.misc import sstruct
7from fontTools.misc.arrayTools import calcIntBounds
8from fontTools.misc.textTools import Tag, bytechr, byteord, bytesjoin, pad
9from fontTools.ttLib import (
10    TTFont,
11    TTLibError,
12    getTableModule,
13    getTableClass,
14    getSearchRange,
15)
16from fontTools.ttLib.sfnt import (
17    SFNTReader,
18    SFNTWriter,
19    DirectoryEntry,
20    WOFFFlavorData,
21    sfntDirectoryFormat,
22    sfntDirectorySize,
23    SFNTDirectoryEntry,
24    sfntDirectoryEntrySize,
25    calcChecksum,
26)
27from fontTools.ttLib.tables import ttProgram, _g_l_y_f
28import logging
29
30
31log = logging.getLogger("fontTools.ttLib.woff2")
32
33haveBrotli = False
34try:
35    try:
36        import brotlicffi as brotli
37    except ImportError:
38        import brotli
39    haveBrotli = True
40except ImportError:
41    pass
42
43
44class WOFF2Reader(SFNTReader):
45    flavor = "woff2"
46
47    def __init__(self, file, checkChecksums=0, fontNumber=-1):
48        if not haveBrotli:
49            log.error(
50                "The WOFF2 decoder requires the Brotli Python extension, available at: "
51                "https://github.com/google/brotli"
52            )
53            raise ImportError("No module named brotli")
54
55        self.file = file
56
57        signature = Tag(self.file.read(4))
58        if signature != b"wOF2":
59            raise TTLibError("Not a WOFF2 font (bad signature)")
60
61        self.file.seek(0)
62        self.DirectoryEntry = WOFF2DirectoryEntry
63        data = self.file.read(woff2DirectorySize)
64        if len(data) != woff2DirectorySize:
65            raise TTLibError("Not a WOFF2 font (not enough data)")
66        sstruct.unpack(woff2DirectoryFormat, data, self)
67
68        self.tables = OrderedDict()
69        offset = 0
70        for i in range(self.numTables):
71            entry = self.DirectoryEntry()
72            entry.fromFile(self.file)
73            tag = Tag(entry.tag)
74            self.tables[tag] = entry
75            entry.offset = offset
76            offset += entry.length
77
78        totalUncompressedSize = offset
79        compressedData = self.file.read(self.totalCompressedSize)
80        decompressedData = brotli.decompress(compressedData)
81        if len(decompressedData) != totalUncompressedSize:
82            raise TTLibError(
83                "unexpected size for decompressed font data: expected %d, found %d"
84                % (totalUncompressedSize, len(decompressedData))
85            )
86        self.transformBuffer = BytesIO(decompressedData)
87
88        self.file.seek(0, 2)
89        if self.length != self.file.tell():
90            raise TTLibError("reported 'length' doesn't match the actual file size")
91
92        self.flavorData = WOFF2FlavorData(self)
93
94        # make empty TTFont to store data while reconstructing tables
95        self.ttFont = TTFont(recalcBBoxes=False, recalcTimestamp=False)
96
97    def __getitem__(self, tag):
98        """Fetch the raw table data. Reconstruct transformed tables."""
99        entry = self.tables[Tag(tag)]
100        if not hasattr(entry, "data"):
101            if entry.transformed:
102                entry.data = self.reconstructTable(tag)
103            else:
104                entry.data = entry.loadData(self.transformBuffer)
105        return entry.data
106
107    def reconstructTable(self, tag):
108        """Reconstruct table named 'tag' from transformed data."""
109        entry = self.tables[Tag(tag)]
110        rawData = entry.loadData(self.transformBuffer)
111        if tag == "glyf":
112            # no need to pad glyph data when reconstructing
113            padding = self.padding if hasattr(self, "padding") else None
114            data = self._reconstructGlyf(rawData, padding)
115        elif tag == "loca":
116            data = self._reconstructLoca()
117        elif tag == "hmtx":
118            data = self._reconstructHmtx(rawData)
119        else:
120            raise TTLibError("transform for table '%s' is unknown" % tag)
121        return data
122
123    def _reconstructGlyf(self, data, padding=None):
124        """Return recostructed glyf table data, and set the corresponding loca's
125        locations. Optionally pad glyph offsets to the specified number of bytes.
126        """
127        self.ttFont["loca"] = WOFF2LocaTable()
128        glyfTable = self.ttFont["glyf"] = WOFF2GlyfTable()
129        glyfTable.reconstruct(data, self.ttFont)
130        if padding:
131            glyfTable.padding = padding
132        data = glyfTable.compile(self.ttFont)
133        return data
134
135    def _reconstructLoca(self):
136        """Return reconstructed loca table data."""
137        if "loca" not in self.ttFont:
138            # make sure glyf is reconstructed first
139            self.tables["glyf"].data = self.reconstructTable("glyf")
140        locaTable = self.ttFont["loca"]
141        data = locaTable.compile(self.ttFont)
142        if len(data) != self.tables["loca"].origLength:
143            raise TTLibError(
144                "reconstructed 'loca' table doesn't match original size: "
145                "expected %d, found %d" % (self.tables["loca"].origLength, len(data))
146            )
147        return data
148
149    def _reconstructHmtx(self, data):
150        """Return reconstructed hmtx table data."""
151        # Before reconstructing 'hmtx' table we need to parse other tables:
152        # 'glyf' is required for reconstructing the sidebearings from the glyphs'
153        # bounding box; 'hhea' is needed for the numberOfHMetrics field.
154        if "glyf" in self.flavorData.transformedTables:
155            # transformed 'glyf' table is self-contained, thus 'loca' not needed
156            tableDependencies = ("maxp", "hhea", "glyf")
157        else:
158            # decompiling untransformed 'glyf' requires 'loca', which requires 'head'
159            tableDependencies = ("maxp", "head", "hhea", "loca", "glyf")
160        for tag in tableDependencies:
161            self._decompileTable(tag)
162        hmtxTable = self.ttFont["hmtx"] = WOFF2HmtxTable()
163        hmtxTable.reconstruct(data, self.ttFont)
164        data = hmtxTable.compile(self.ttFont)
165        return data
166
167    def _decompileTable(self, tag):
168        """Decompile table data and store it inside self.ttFont."""
169        data = self[tag]
170        if self.ttFont.isLoaded(tag):
171            return self.ttFont[tag]
172        tableClass = getTableClass(tag)
173        table = tableClass(tag)
174        self.ttFont.tables[tag] = table
175        table.decompile(data, self.ttFont)
176
177
178class WOFF2Writer(SFNTWriter):
179    flavor = "woff2"
180
181    def __init__(
182        self,
183        file,
184        numTables,
185        sfntVersion="\000\001\000\000",
186        flavor=None,
187        flavorData=None,
188    ):
189        if not haveBrotli:
190            log.error(
191                "The WOFF2 encoder requires the Brotli Python extension, available at: "
192                "https://github.com/google/brotli"
193            )
194            raise ImportError("No module named brotli")
195
196        self.file = file
197        self.numTables = numTables
198        self.sfntVersion = Tag(sfntVersion)
199        self.flavorData = WOFF2FlavorData(data=flavorData)
200
201        self.directoryFormat = woff2DirectoryFormat
202        self.directorySize = woff2DirectorySize
203        self.DirectoryEntry = WOFF2DirectoryEntry
204
205        self.signature = Tag("wOF2")
206
207        self.nextTableOffset = 0
208        self.transformBuffer = BytesIO()
209
210        self.tables = OrderedDict()
211
212        # make empty TTFont to store data while normalising and transforming tables
213        self.ttFont = TTFont(recalcBBoxes=False, recalcTimestamp=False)
214
215    def __setitem__(self, tag, data):
216        """Associate new entry named 'tag' with raw table data."""
217        if tag in self.tables:
218            raise TTLibError("cannot rewrite '%s' table" % tag)
219        if tag == "DSIG":
220            # always drop DSIG table, since the encoding process can invalidate it
221            self.numTables -= 1
222            return
223
224        entry = self.DirectoryEntry()
225        entry.tag = Tag(tag)
226        entry.flags = getKnownTagIndex(entry.tag)
227        # WOFF2 table data are written to disk only on close(), after all tags
228        # have been specified
229        entry.data = data
230
231        self.tables[tag] = entry
232
233    def close(self):
234        """All tags must have been specified. Now write the table data and directory."""
235        if len(self.tables) != self.numTables:
236            raise TTLibError(
237                "wrong number of tables; expected %d, found %d"
238                % (self.numTables, len(self.tables))
239            )
240
241        if self.sfntVersion in ("\x00\x01\x00\x00", "true"):
242            isTrueType = True
243        elif self.sfntVersion == "OTTO":
244            isTrueType = False
245        else:
246            raise TTLibError("Not a TrueType or OpenType font (bad sfntVersion)")
247
248        # The WOFF2 spec no longer requires the glyph offsets to be 4-byte aligned.
249        # However, the reference WOFF2 implementation still fails to reconstruct
250        # 'unpadded' glyf tables, therefore we need to 'normalise' them.
251        # See:
252        # https://github.com/khaledhosny/ots/issues/60
253        # https://github.com/google/woff2/issues/15
254        if (
255            isTrueType
256            and "glyf" in self.flavorData.transformedTables
257            and "glyf" in self.tables
258        ):
259            self._normaliseGlyfAndLoca(padding=4)
260        self._setHeadTransformFlag()
261
262        # To pass the legacy OpenType Sanitiser currently included in browsers,
263        # we must sort the table directory and data alphabetically by tag.
264        # See:
265        # https://github.com/google/woff2/pull/3
266        # https://lists.w3.org/Archives/Public/public-webfonts-wg/2015Mar/0000.html
267        #
268        # 2023: We rely on this in _transformTables where we expect that
269        # "loca" comes after "glyf" table.
270        self.tables = OrderedDict(sorted(self.tables.items()))
271
272        self.totalSfntSize = self._calcSFNTChecksumsLengthsAndOffsets()
273
274        fontData = self._transformTables()
275        compressedFont = brotli.compress(fontData, mode=brotli.MODE_FONT)
276
277        self.totalCompressedSize = len(compressedFont)
278        self.length = self._calcTotalSize()
279        self.majorVersion, self.minorVersion = self._getVersion()
280        self.reserved = 0
281
282        directory = self._packTableDirectory()
283        self.file.seek(0)
284        self.file.write(pad(directory + compressedFont, size=4))
285        self._writeFlavorData()
286
287    def _normaliseGlyfAndLoca(self, padding=4):
288        """Recompile glyf and loca tables, aligning glyph offsets to multiples of
289        'padding' size. Update the head table's 'indexToLocFormat' accordingly while
290        compiling loca.
291        """
292        if self.sfntVersion == "OTTO":
293            return
294
295        for tag in ("maxp", "head", "loca", "glyf", "fvar"):
296            if tag in self.tables:
297                self._decompileTable(tag)
298        self.ttFont["glyf"].padding = padding
299        for tag in ("glyf", "loca"):
300            self._compileTable(tag)
301
302    def _setHeadTransformFlag(self):
303        """Set bit 11 of 'head' table flags to indicate that the font has undergone
304        a lossless modifying transform. Re-compile head table data."""
305        self._decompileTable("head")
306        self.ttFont["head"].flags |= 1 << 11
307        self._compileTable("head")
308
309    def _decompileTable(self, tag):
310        """Fetch table data, decompile it, and store it inside self.ttFont."""
311        tag = Tag(tag)
312        if tag not in self.tables:
313            raise TTLibError("missing required table: %s" % tag)
314        if self.ttFont.isLoaded(tag):
315            return
316        data = self.tables[tag].data
317        if tag == "loca":
318            tableClass = WOFF2LocaTable
319        elif tag == "glyf":
320            tableClass = WOFF2GlyfTable
321        elif tag == "hmtx":
322            tableClass = WOFF2HmtxTable
323        else:
324            tableClass = getTableClass(tag)
325        table = tableClass(tag)
326        self.ttFont.tables[tag] = table
327        table.decompile(data, self.ttFont)
328
329    def _compileTable(self, tag):
330        """Compile table and store it in its 'data' attribute."""
331        self.tables[tag].data = self.ttFont[tag].compile(self.ttFont)
332
333    def _calcSFNTChecksumsLengthsAndOffsets(self):
334        """Compute the 'original' SFNT checksums, lengths and offsets for checksum
335        adjustment calculation. Return the total size of the uncompressed font.
336        """
337        offset = sfntDirectorySize + sfntDirectoryEntrySize * len(self.tables)
338        for tag, entry in self.tables.items():
339            data = entry.data
340            entry.origOffset = offset
341            entry.origLength = len(data)
342            if tag == "head":
343                entry.checkSum = calcChecksum(data[:8] + b"\0\0\0\0" + data[12:])
344            else:
345                entry.checkSum = calcChecksum(data)
346            offset += (entry.origLength + 3) & ~3
347        return offset
348
349    def _transformTables(self):
350        """Return transformed font data."""
351        transformedTables = self.flavorData.transformedTables
352        for tag, entry in self.tables.items():
353            data = None
354            if tag in transformedTables:
355                data = self.transformTable(tag)
356                if data is not None:
357                    entry.transformed = True
358            if data is None:
359                if tag == "glyf":
360                    # Currently we always sort table tags so
361                    # 'loca' comes after 'glyf'.
362                    transformedTables.discard("loca")
363                # pass-through the table data without transformation
364                data = entry.data
365                entry.transformed = False
366            entry.offset = self.nextTableOffset
367            entry.saveData(self.transformBuffer, data)
368            self.nextTableOffset += entry.length
369        self.writeMasterChecksum()
370        fontData = self.transformBuffer.getvalue()
371        return fontData
372
373    def transformTable(self, tag):
374        """Return transformed table data, or None if some pre-conditions aren't
375        met -- in which case, the non-transformed table data will be used.
376        """
377        if tag == "loca":
378            data = b""
379        elif tag == "glyf":
380            for tag in ("maxp", "head", "loca", "glyf"):
381                self._decompileTable(tag)
382            glyfTable = self.ttFont["glyf"]
383            data = glyfTable.transform(self.ttFont)
384        elif tag == "hmtx":
385            if "glyf" not in self.tables:
386                return
387            for tag in ("maxp", "head", "hhea", "loca", "glyf", "hmtx"):
388                self._decompileTable(tag)
389            hmtxTable = self.ttFont["hmtx"]
390            data = hmtxTable.transform(self.ttFont)  # can be None
391        else:
392            raise TTLibError("Transform for table '%s' is unknown" % tag)
393        return data
394
395    def _calcMasterChecksum(self):
396        """Calculate checkSumAdjustment."""
397        tags = list(self.tables.keys())
398        checksums = []
399        for i in range(len(tags)):
400            checksums.append(self.tables[tags[i]].checkSum)
401
402        # Create a SFNT directory for checksum calculation purposes
403        self.searchRange, self.entrySelector, self.rangeShift = getSearchRange(
404            self.numTables, 16
405        )
406        directory = sstruct.pack(sfntDirectoryFormat, self)
407        tables = sorted(self.tables.items())
408        for tag, entry in tables:
409            sfntEntry = SFNTDirectoryEntry()
410            sfntEntry.tag = entry.tag
411            sfntEntry.checkSum = entry.checkSum
412            sfntEntry.offset = entry.origOffset
413            sfntEntry.length = entry.origLength
414            directory = directory + sfntEntry.toString()
415
416        directory_end = sfntDirectorySize + len(self.tables) * sfntDirectoryEntrySize
417        assert directory_end == len(directory)
418
419        checksums.append(calcChecksum(directory))
420        checksum = sum(checksums) & 0xFFFFFFFF
421        # BiboAfba!
422        checksumadjustment = (0xB1B0AFBA - checksum) & 0xFFFFFFFF
423        return checksumadjustment
424
425    def writeMasterChecksum(self):
426        """Write checkSumAdjustment to the transformBuffer."""
427        checksumadjustment = self._calcMasterChecksum()
428        self.transformBuffer.seek(self.tables["head"].offset + 8)
429        self.transformBuffer.write(struct.pack(">L", checksumadjustment))
430
431    def _calcTotalSize(self):
432        """Calculate total size of WOFF2 font, including any meta- and/or private data."""
433        offset = self.directorySize
434        for entry in self.tables.values():
435            offset += len(entry.toString())
436        offset += self.totalCompressedSize
437        offset = (offset + 3) & ~3
438        offset = self._calcFlavorDataOffsetsAndSize(offset)
439        return offset
440
441    def _calcFlavorDataOffsetsAndSize(self, start):
442        """Calculate offsets and lengths for any meta- and/or private data."""
443        offset = start
444        data = self.flavorData
445        if data.metaData:
446            self.metaOrigLength = len(data.metaData)
447            self.metaOffset = offset
448            self.compressedMetaData = brotli.compress(
449                data.metaData, mode=brotli.MODE_TEXT
450            )
451            self.metaLength = len(self.compressedMetaData)
452            offset += self.metaLength
453        else:
454            self.metaOffset = self.metaLength = self.metaOrigLength = 0
455            self.compressedMetaData = b""
456        if data.privData:
457            # make sure private data is padded to 4-byte boundary
458            offset = (offset + 3) & ~3
459            self.privOffset = offset
460            self.privLength = len(data.privData)
461            offset += self.privLength
462        else:
463            self.privOffset = self.privLength = 0
464        return offset
465
466    def _getVersion(self):
467        """Return the WOFF2 font's (majorVersion, minorVersion) tuple."""
468        data = self.flavorData
469        if data.majorVersion is not None and data.minorVersion is not None:
470            return data.majorVersion, data.minorVersion
471        else:
472            # if None, return 'fontRevision' from 'head' table
473            if "head" in self.tables:
474                return struct.unpack(">HH", self.tables["head"].data[4:8])
475            else:
476                return 0, 0
477
478    def _packTableDirectory(self):
479        """Return WOFF2 table directory data."""
480        directory = sstruct.pack(self.directoryFormat, self)
481        for entry in self.tables.values():
482            directory = directory + entry.toString()
483        return directory
484
485    def _writeFlavorData(self):
486        """Write metadata and/or private data using appropiate padding."""
487        compressedMetaData = self.compressedMetaData
488        privData = self.flavorData.privData
489        if compressedMetaData and privData:
490            compressedMetaData = pad(compressedMetaData, size=4)
491        if compressedMetaData:
492            self.file.seek(self.metaOffset)
493            assert self.file.tell() == self.metaOffset
494            self.file.write(compressedMetaData)
495        if privData:
496            self.file.seek(self.privOffset)
497            assert self.file.tell() == self.privOffset
498            self.file.write(privData)
499
500    def reordersTables(self):
501        return True
502
503
504# -- woff2 directory helpers and cruft
505
506woff2DirectoryFormat = """
507		> # big endian
508		signature:           4s   # "wOF2"
509		sfntVersion:         4s
510		length:              L    # total woff2 file size
511		numTables:           H    # number of tables
512		reserved:            H    # set to 0
513		totalSfntSize:       L    # uncompressed size
514		totalCompressedSize: L    # compressed size
515		majorVersion:        H    # major version of WOFF file
516		minorVersion:        H    # minor version of WOFF file
517		metaOffset:          L    # offset to metadata block
518		metaLength:          L    # length of compressed metadata
519		metaOrigLength:      L    # length of uncompressed metadata
520		privOffset:          L    # offset to private data block
521		privLength:          L    # length of private data block
522"""
523
524woff2DirectorySize = sstruct.calcsize(woff2DirectoryFormat)
525
526woff2KnownTags = (
527    "cmap",
528    "head",
529    "hhea",
530    "hmtx",
531    "maxp",
532    "name",
533    "OS/2",
534    "post",
535    "cvt ",
536    "fpgm",
537    "glyf",
538    "loca",
539    "prep",
540    "CFF ",
541    "VORG",
542    "EBDT",
543    "EBLC",
544    "gasp",
545    "hdmx",
546    "kern",
547    "LTSH",
548    "PCLT",
549    "VDMX",
550    "vhea",
551    "vmtx",
552    "BASE",
553    "GDEF",
554    "GPOS",
555    "GSUB",
556    "EBSC",
557    "JSTF",
558    "MATH",
559    "CBDT",
560    "CBLC",
561    "COLR",
562    "CPAL",
563    "SVG ",
564    "sbix",
565    "acnt",
566    "avar",
567    "bdat",
568    "bloc",
569    "bsln",
570    "cvar",
571    "fdsc",
572    "feat",
573    "fmtx",
574    "fvar",
575    "gvar",
576    "hsty",
577    "just",
578    "lcar",
579    "mort",
580    "morx",
581    "opbd",
582    "prop",
583    "trak",
584    "Zapf",
585    "Silf",
586    "Glat",
587    "Gloc",
588    "Feat",
589    "Sill",
590)
591
592woff2FlagsFormat = """
593		> # big endian
594		flags: B  # table type and flags
595"""
596
597woff2FlagsSize = sstruct.calcsize(woff2FlagsFormat)
598
599woff2UnknownTagFormat = """
600		> # big endian
601		tag: 4s  # 4-byte tag (optional)
602"""
603
604woff2UnknownTagSize = sstruct.calcsize(woff2UnknownTagFormat)
605
606woff2UnknownTagIndex = 0x3F
607
608woff2Base128MaxSize = 5
609woff2DirectoryEntryMaxSize = (
610    woff2FlagsSize + woff2UnknownTagSize + 2 * woff2Base128MaxSize
611)
612
613woff2TransformedTableTags = ("glyf", "loca")
614
615woff2GlyfTableFormat = """
616		> # big endian
617		version:                  H  # = 0x0000
618		optionFlags:              H  # Bit 0: we have overlapSimpleBitmap[], Bits 1-15: reserved
619		numGlyphs:                H  # Number of glyphs
620		indexFormat:              H  # Offset format for loca table
621		nContourStreamSize:       L  # Size of nContour stream
622		nPointsStreamSize:        L  # Size of nPoints stream
623		flagStreamSize:           L  # Size of flag stream
624		glyphStreamSize:          L  # Size of glyph stream
625		compositeStreamSize:      L  # Size of composite stream
626		bboxStreamSize:           L  # Comnined size of bboxBitmap and bboxStream
627		instructionStreamSize:    L  # Size of instruction stream
628"""
629
630woff2GlyfTableFormatSize = sstruct.calcsize(woff2GlyfTableFormat)
631
632bboxFormat = """
633		>	# big endian
634		xMin:				h
635		yMin:				h
636		xMax:				h
637		yMax:				h
638"""
639
640woff2OverlapSimpleBitmapFlag = 0x0001
641
642
643def getKnownTagIndex(tag):
644    """Return index of 'tag' in woff2KnownTags list. Return 63 if not found."""
645    for i in range(len(woff2KnownTags)):
646        if tag == woff2KnownTags[i]:
647            return i
648    return woff2UnknownTagIndex
649
650
651class WOFF2DirectoryEntry(DirectoryEntry):
652    def fromFile(self, file):
653        pos = file.tell()
654        data = file.read(woff2DirectoryEntryMaxSize)
655        left = self.fromString(data)
656        consumed = len(data) - len(left)
657        file.seek(pos + consumed)
658
659    def fromString(self, data):
660        if len(data) < 1:
661            raise TTLibError("can't read table 'flags': not enough data")
662        dummy, data = sstruct.unpack2(woff2FlagsFormat, data, self)
663        if self.flags & 0x3F == 0x3F:
664            # if bits [0..5] of the flags byte == 63, read a 4-byte arbitrary tag value
665            if len(data) < woff2UnknownTagSize:
666                raise TTLibError("can't read table 'tag': not enough data")
667            dummy, data = sstruct.unpack2(woff2UnknownTagFormat, data, self)
668        else:
669            # otherwise, tag is derived from a fixed 'Known Tags' table
670            self.tag = woff2KnownTags[self.flags & 0x3F]
671        self.tag = Tag(self.tag)
672        self.origLength, data = unpackBase128(data)
673        self.length = self.origLength
674        if self.transformed:
675            self.length, data = unpackBase128(data)
676            if self.tag == "loca" and self.length != 0:
677                raise TTLibError("the transformLength of the 'loca' table must be 0")
678        # return left over data
679        return data
680
681    def toString(self):
682        data = bytechr(self.flags)
683        if (self.flags & 0x3F) == 0x3F:
684            data += struct.pack(">4s", self.tag.tobytes())
685        data += packBase128(self.origLength)
686        if self.transformed:
687            data += packBase128(self.length)
688        return data
689
690    @property
691    def transformVersion(self):
692        """Return bits 6-7 of table entry's flags, which indicate the preprocessing
693        transformation version number (between 0 and 3).
694        """
695        return self.flags >> 6
696
697    @transformVersion.setter
698    def transformVersion(self, value):
699        assert 0 <= value <= 3
700        self.flags |= value << 6
701
702    @property
703    def transformed(self):
704        """Return True if the table has any transformation, else return False."""
705        # For all tables in a font, except for 'glyf' and 'loca', the transformation
706        # version 0 indicates the null transform (where the original table data is
707        # passed directly to the Brotli compressor). For 'glyf' and 'loca' tables,
708        # transformation version 3 indicates the null transform
709        if self.tag in {"glyf", "loca"}:
710            return self.transformVersion != 3
711        else:
712            return self.transformVersion != 0
713
714    @transformed.setter
715    def transformed(self, booleanValue):
716        # here we assume that a non-null transform means version 0 for 'glyf' and
717        # 'loca' and 1 for every other table (e.g. hmtx); but that may change as
718        # new transformation formats are introduced in the future (if ever).
719        if self.tag in {"glyf", "loca"}:
720            self.transformVersion = 3 if not booleanValue else 0
721        else:
722            self.transformVersion = int(booleanValue)
723
724
725class WOFF2LocaTable(getTableClass("loca")):
726    """Same as parent class. The only difference is that it attempts to preserve
727    the 'indexFormat' as encoded in the WOFF2 glyf table.
728    """
729
730    def __init__(self, tag=None):
731        self.tableTag = Tag(tag or "loca")
732
733    def compile(self, ttFont):
734        try:
735            max_location = max(self.locations)
736        except AttributeError:
737            self.set([])
738            max_location = 0
739        if "glyf" in ttFont and hasattr(ttFont["glyf"], "indexFormat"):
740            # copile loca using the indexFormat specified in the WOFF2 glyf table
741            indexFormat = ttFont["glyf"].indexFormat
742            if indexFormat == 0:
743                if max_location >= 0x20000:
744                    raise TTLibError("indexFormat is 0 but local offsets > 0x20000")
745                if not all(l % 2 == 0 for l in self.locations):
746                    raise TTLibError(
747                        "indexFormat is 0 but local offsets not multiples of 2"
748                    )
749                locations = array.array("H")
750                for i in range(len(self.locations)):
751                    locations.append(self.locations[i] // 2)
752            else:
753                locations = array.array("I", self.locations)
754            if sys.byteorder != "big":
755                locations.byteswap()
756            data = locations.tobytes()
757        else:
758            # use the most compact indexFormat given the current glyph offsets
759            data = super(WOFF2LocaTable, self).compile(ttFont)
760        return data
761
762
763class WOFF2GlyfTable(getTableClass("glyf")):
764    """Decoder/Encoder for WOFF2 'glyf' table transform."""
765
766    subStreams = (
767        "nContourStream",
768        "nPointsStream",
769        "flagStream",
770        "glyphStream",
771        "compositeStream",
772        "bboxStream",
773        "instructionStream",
774    )
775
776    def __init__(self, tag=None):
777        self.tableTag = Tag(tag or "glyf")
778
779    def reconstruct(self, data, ttFont):
780        """Decompile transformed 'glyf' data."""
781        inputDataSize = len(data)
782
783        if inputDataSize < woff2GlyfTableFormatSize:
784            raise TTLibError("not enough 'glyf' data")
785        dummy, data = sstruct.unpack2(woff2GlyfTableFormat, data, self)
786        offset = woff2GlyfTableFormatSize
787
788        for stream in self.subStreams:
789            size = getattr(self, stream + "Size")
790            setattr(self, stream, data[:size])
791            data = data[size:]
792            offset += size
793
794        hasOverlapSimpleBitmap = self.optionFlags & woff2OverlapSimpleBitmapFlag
795        self.overlapSimpleBitmap = None
796        if hasOverlapSimpleBitmap:
797            overlapSimpleBitmapSize = (self.numGlyphs + 7) >> 3
798            self.overlapSimpleBitmap = array.array("B", data[:overlapSimpleBitmapSize])
799            offset += overlapSimpleBitmapSize
800
801        if offset != inputDataSize:
802            raise TTLibError(
803                "incorrect size of transformed 'glyf' table: expected %d, received %d bytes"
804                % (offset, inputDataSize)
805            )
806
807        bboxBitmapSize = ((self.numGlyphs + 31) >> 5) << 2
808        bboxBitmap = self.bboxStream[:bboxBitmapSize]
809        self.bboxBitmap = array.array("B", bboxBitmap)
810        self.bboxStream = self.bboxStream[bboxBitmapSize:]
811
812        self.nContourStream = array.array("h", self.nContourStream)
813        if sys.byteorder != "big":
814            self.nContourStream.byteswap()
815        assert len(self.nContourStream) == self.numGlyphs
816
817        if "head" in ttFont:
818            ttFont["head"].indexToLocFormat = self.indexFormat
819        try:
820            self.glyphOrder = ttFont.getGlyphOrder()
821        except:
822            self.glyphOrder = None
823        if self.glyphOrder is None:
824            self.glyphOrder = [".notdef"]
825            self.glyphOrder.extend(["glyph%.5d" % i for i in range(1, self.numGlyphs)])
826        else:
827            if len(self.glyphOrder) != self.numGlyphs:
828                raise TTLibError(
829                    "incorrect glyphOrder: expected %d glyphs, found %d"
830                    % (len(self.glyphOrder), self.numGlyphs)
831                )
832
833        glyphs = self.glyphs = {}
834        for glyphID, glyphName in enumerate(self.glyphOrder):
835            glyph = self._decodeGlyph(glyphID)
836            glyphs[glyphName] = glyph
837
838    def transform(self, ttFont):
839        """Return transformed 'glyf' data"""
840        self.numGlyphs = len(self.glyphs)
841        assert len(self.glyphOrder) == self.numGlyphs
842        if "maxp" in ttFont:
843            ttFont["maxp"].numGlyphs = self.numGlyphs
844        self.indexFormat = ttFont["head"].indexToLocFormat
845
846        for stream in self.subStreams:
847            setattr(self, stream, b"")
848        bboxBitmapSize = ((self.numGlyphs + 31) >> 5) << 2
849        self.bboxBitmap = array.array("B", [0] * bboxBitmapSize)
850
851        self.overlapSimpleBitmap = array.array("B", [0] * ((self.numGlyphs + 7) >> 3))
852        for glyphID in range(self.numGlyphs):
853            try:
854                self._encodeGlyph(glyphID)
855            except NotImplementedError:
856                return None
857        hasOverlapSimpleBitmap = any(self.overlapSimpleBitmap)
858
859        self.bboxStream = self.bboxBitmap.tobytes() + self.bboxStream
860        for stream in self.subStreams:
861            setattr(self, stream + "Size", len(getattr(self, stream)))
862        self.version = 0
863        self.optionFlags = 0
864        if hasOverlapSimpleBitmap:
865            self.optionFlags |= woff2OverlapSimpleBitmapFlag
866        data = sstruct.pack(woff2GlyfTableFormat, self)
867        data += bytesjoin([getattr(self, s) for s in self.subStreams])
868        if hasOverlapSimpleBitmap:
869            data += self.overlapSimpleBitmap.tobytes()
870        return data
871
872    def _decodeGlyph(self, glyphID):
873        glyph = getTableModule("glyf").Glyph()
874        glyph.numberOfContours = self.nContourStream[glyphID]
875        if glyph.numberOfContours == 0:
876            return glyph
877        elif glyph.isComposite():
878            self._decodeComponents(glyph)
879        else:
880            self._decodeCoordinates(glyph)
881            self._decodeOverlapSimpleFlag(glyph, glyphID)
882        self._decodeBBox(glyphID, glyph)
883        return glyph
884
885    def _decodeComponents(self, glyph):
886        data = self.compositeStream
887        glyph.components = []
888        more = 1
889        haveInstructions = 0
890        while more:
891            component = getTableModule("glyf").GlyphComponent()
892            more, haveInstr, data = component.decompile(data, self)
893            haveInstructions = haveInstructions | haveInstr
894            glyph.components.append(component)
895        self.compositeStream = data
896        if haveInstructions:
897            self._decodeInstructions(glyph)
898
899    def _decodeCoordinates(self, glyph):
900        data = self.nPointsStream
901        endPtsOfContours = []
902        endPoint = -1
903        for i in range(glyph.numberOfContours):
904            ptsOfContour, data = unpack255UShort(data)
905            endPoint += ptsOfContour
906            endPtsOfContours.append(endPoint)
907        glyph.endPtsOfContours = endPtsOfContours
908        self.nPointsStream = data
909        self._decodeTriplets(glyph)
910        self._decodeInstructions(glyph)
911
912    def _decodeOverlapSimpleFlag(self, glyph, glyphID):
913        if self.overlapSimpleBitmap is None or glyph.numberOfContours <= 0:
914            return
915        byte = glyphID >> 3
916        bit = glyphID & 7
917        if self.overlapSimpleBitmap[byte] & (0x80 >> bit):
918            glyph.flags[0] |= _g_l_y_f.flagOverlapSimple
919
920    def _decodeInstructions(self, glyph):
921        glyphStream = self.glyphStream
922        instructionStream = self.instructionStream
923        instructionLength, glyphStream = unpack255UShort(glyphStream)
924        glyph.program = ttProgram.Program()
925        glyph.program.fromBytecode(instructionStream[:instructionLength])
926        self.glyphStream = glyphStream
927        self.instructionStream = instructionStream[instructionLength:]
928
929    def _decodeBBox(self, glyphID, glyph):
930        haveBBox = bool(self.bboxBitmap[glyphID >> 3] & (0x80 >> (glyphID & 7)))
931        if glyph.isComposite() and not haveBBox:
932            raise TTLibError("no bbox values for composite glyph %d" % glyphID)
933        if haveBBox:
934            dummy, self.bboxStream = sstruct.unpack2(bboxFormat, self.bboxStream, glyph)
935        else:
936            glyph.recalcBounds(self)
937
938    def _decodeTriplets(self, glyph):
939        def withSign(flag, baseval):
940            assert 0 <= baseval and baseval < 65536, "integer overflow"
941            return baseval if flag & 1 else -baseval
942
943        nPoints = glyph.endPtsOfContours[-1] + 1
944        flagSize = nPoints
945        if flagSize > len(self.flagStream):
946            raise TTLibError("not enough 'flagStream' data")
947        flagsData = self.flagStream[:flagSize]
948        self.flagStream = self.flagStream[flagSize:]
949        flags = array.array("B", flagsData)
950
951        triplets = array.array("B", self.glyphStream)
952        nTriplets = len(triplets)
953        assert nPoints <= nTriplets
954
955        x = 0
956        y = 0
957        glyph.coordinates = getTableModule("glyf").GlyphCoordinates.zeros(nPoints)
958        glyph.flags = array.array("B")
959        tripletIndex = 0
960        for i in range(nPoints):
961            flag = flags[i]
962            onCurve = not bool(flag >> 7)
963            flag &= 0x7F
964            if flag < 84:
965                nBytes = 1
966            elif flag < 120:
967                nBytes = 2
968            elif flag < 124:
969                nBytes = 3
970            else:
971                nBytes = 4
972            assert (tripletIndex + nBytes) <= nTriplets
973            if flag < 10:
974                dx = 0
975                dy = withSign(flag, ((flag & 14) << 7) + triplets[tripletIndex])
976            elif flag < 20:
977                dx = withSign(flag, (((flag - 10) & 14) << 7) + triplets[tripletIndex])
978                dy = 0
979            elif flag < 84:
980                b0 = flag - 20
981                b1 = triplets[tripletIndex]
982                dx = withSign(flag, 1 + (b0 & 0x30) + (b1 >> 4))
983                dy = withSign(flag >> 1, 1 + ((b0 & 0x0C) << 2) + (b1 & 0x0F))
984            elif flag < 120:
985                b0 = flag - 84
986                dx = withSign(flag, 1 + ((b0 // 12) << 8) + triplets[tripletIndex])
987                dy = withSign(
988                    flag >> 1, 1 + (((b0 % 12) >> 2) << 8) + triplets[tripletIndex + 1]
989                )
990            elif flag < 124:
991                b2 = triplets[tripletIndex + 1]
992                dx = withSign(flag, (triplets[tripletIndex] << 4) + (b2 >> 4))
993                dy = withSign(
994                    flag >> 1, ((b2 & 0x0F) << 8) + triplets[tripletIndex + 2]
995                )
996            else:
997                dx = withSign(
998                    flag, (triplets[tripletIndex] << 8) + triplets[tripletIndex + 1]
999                )
1000                dy = withSign(
1001                    flag >> 1,
1002                    (triplets[tripletIndex + 2] << 8) + triplets[tripletIndex + 3],
1003                )
1004            tripletIndex += nBytes
1005            x += dx
1006            y += dy
1007            glyph.coordinates[i] = (x, y)
1008            glyph.flags.append(int(onCurve))
1009        bytesConsumed = tripletIndex
1010        self.glyphStream = self.glyphStream[bytesConsumed:]
1011
1012    def _encodeGlyph(self, glyphID):
1013        glyphName = self.getGlyphName(glyphID)
1014        glyph = self[glyphName]
1015        self.nContourStream += struct.pack(">h", glyph.numberOfContours)
1016        if glyph.numberOfContours == 0:
1017            return
1018        elif glyph.isComposite():
1019            self._encodeComponents(glyph)
1020        elif glyph.isVarComposite():
1021            raise NotImplementedError
1022        else:
1023            self._encodeCoordinates(glyph)
1024            self._encodeOverlapSimpleFlag(glyph, glyphID)
1025        self._encodeBBox(glyphID, glyph)
1026
1027    def _encodeComponents(self, glyph):
1028        lastcomponent = len(glyph.components) - 1
1029        more = 1
1030        haveInstructions = 0
1031        for i in range(len(glyph.components)):
1032            if i == lastcomponent:
1033                haveInstructions = hasattr(glyph, "program")
1034                more = 0
1035            component = glyph.components[i]
1036            self.compositeStream += component.compile(more, haveInstructions, self)
1037        if haveInstructions:
1038            self._encodeInstructions(glyph)
1039
1040    def _encodeCoordinates(self, glyph):
1041        lastEndPoint = -1
1042        if _g_l_y_f.flagCubic in glyph.flags:
1043            raise NotImplementedError
1044        for endPoint in glyph.endPtsOfContours:
1045            ptsOfContour = endPoint - lastEndPoint
1046            self.nPointsStream += pack255UShort(ptsOfContour)
1047            lastEndPoint = endPoint
1048        self._encodeTriplets(glyph)
1049        self._encodeInstructions(glyph)
1050
1051    def _encodeOverlapSimpleFlag(self, glyph, glyphID):
1052        if glyph.numberOfContours <= 0:
1053            return
1054        if glyph.flags[0] & _g_l_y_f.flagOverlapSimple:
1055            byte = glyphID >> 3
1056            bit = glyphID & 7
1057            self.overlapSimpleBitmap[byte] |= 0x80 >> bit
1058
1059    def _encodeInstructions(self, glyph):
1060        instructions = glyph.program.getBytecode()
1061        self.glyphStream += pack255UShort(len(instructions))
1062        self.instructionStream += instructions
1063
1064    def _encodeBBox(self, glyphID, glyph):
1065        assert glyph.numberOfContours != 0, "empty glyph has no bbox"
1066        if not glyph.isComposite():
1067            # for simple glyphs, compare the encoded bounding box info with the calculated
1068            # values, and if they match omit the bounding box info
1069            currentBBox = glyph.xMin, glyph.yMin, glyph.xMax, glyph.yMax
1070            calculatedBBox = calcIntBounds(glyph.coordinates)
1071            if currentBBox == calculatedBBox:
1072                return
1073        self.bboxBitmap[glyphID >> 3] |= 0x80 >> (glyphID & 7)
1074        self.bboxStream += sstruct.pack(bboxFormat, glyph)
1075
1076    def _encodeTriplets(self, glyph):
1077        assert len(glyph.coordinates) == len(glyph.flags)
1078        coordinates = glyph.coordinates.copy()
1079        coordinates.absoluteToRelative()
1080
1081        flags = array.array("B")
1082        triplets = array.array("B")
1083        for i in range(len(coordinates)):
1084            onCurve = glyph.flags[i] & _g_l_y_f.flagOnCurve
1085            x, y = coordinates[i]
1086            absX = abs(x)
1087            absY = abs(y)
1088            onCurveBit = 0 if onCurve else 128
1089            xSignBit = 0 if (x < 0) else 1
1090            ySignBit = 0 if (y < 0) else 1
1091            xySignBits = xSignBit + 2 * ySignBit
1092
1093            if x == 0 and absY < 1280:
1094                flags.append(onCurveBit + ((absY & 0xF00) >> 7) + ySignBit)
1095                triplets.append(absY & 0xFF)
1096            elif y == 0 and absX < 1280:
1097                flags.append(onCurveBit + 10 + ((absX & 0xF00) >> 7) + xSignBit)
1098                triplets.append(absX & 0xFF)
1099            elif absX < 65 and absY < 65:
1100                flags.append(
1101                    onCurveBit
1102                    + 20
1103                    + ((absX - 1) & 0x30)
1104                    + (((absY - 1) & 0x30) >> 2)
1105                    + xySignBits
1106                )
1107                triplets.append((((absX - 1) & 0xF) << 4) | ((absY - 1) & 0xF))
1108            elif absX < 769 and absY < 769:
1109                flags.append(
1110                    onCurveBit
1111                    + 84
1112                    + 12 * (((absX - 1) & 0x300) >> 8)
1113                    + (((absY - 1) & 0x300) >> 6)
1114                    + xySignBits
1115                )
1116                triplets.append((absX - 1) & 0xFF)
1117                triplets.append((absY - 1) & 0xFF)
1118            elif absX < 4096 and absY < 4096:
1119                flags.append(onCurveBit + 120 + xySignBits)
1120                triplets.append(absX >> 4)
1121                triplets.append(((absX & 0xF) << 4) | (absY >> 8))
1122                triplets.append(absY & 0xFF)
1123            else:
1124                flags.append(onCurveBit + 124 + xySignBits)
1125                triplets.append(absX >> 8)
1126                triplets.append(absX & 0xFF)
1127                triplets.append(absY >> 8)
1128                triplets.append(absY & 0xFF)
1129
1130        self.flagStream += flags.tobytes()
1131        self.glyphStream += triplets.tobytes()
1132
1133
1134class WOFF2HmtxTable(getTableClass("hmtx")):
1135    def __init__(self, tag=None):
1136        self.tableTag = Tag(tag or "hmtx")
1137
1138    def reconstruct(self, data, ttFont):
1139        (flags,) = struct.unpack(">B", data[:1])
1140        data = data[1:]
1141        if flags & 0b11111100 != 0:
1142            raise TTLibError("Bits 2-7 of '%s' flags are reserved" % self.tableTag)
1143
1144        # When bit 0 is _not_ set, the lsb[] array is present
1145        hasLsbArray = flags & 1 == 0
1146        # When bit 1 is _not_ set, the leftSideBearing[] array is present
1147        hasLeftSideBearingArray = flags & 2 == 0
1148        if hasLsbArray and hasLeftSideBearingArray:
1149            raise TTLibError(
1150                "either bits 0 or 1 (or both) must set in transformed '%s' flags"
1151                % self.tableTag
1152            )
1153
1154        glyfTable = ttFont["glyf"]
1155        headerTable = ttFont["hhea"]
1156        glyphOrder = glyfTable.glyphOrder
1157        numGlyphs = len(glyphOrder)
1158        numberOfHMetrics = min(int(headerTable.numberOfHMetrics), numGlyphs)
1159
1160        assert len(data) >= 2 * numberOfHMetrics
1161        advanceWidthArray = array.array("H", data[: 2 * numberOfHMetrics])
1162        if sys.byteorder != "big":
1163            advanceWidthArray.byteswap()
1164        data = data[2 * numberOfHMetrics :]
1165
1166        if hasLsbArray:
1167            assert len(data) >= 2 * numberOfHMetrics
1168            lsbArray = array.array("h", data[: 2 * numberOfHMetrics])
1169            if sys.byteorder != "big":
1170                lsbArray.byteswap()
1171            data = data[2 * numberOfHMetrics :]
1172        else:
1173            # compute (proportional) glyphs' lsb from their xMin
1174            lsbArray = array.array("h")
1175            for i, glyphName in enumerate(glyphOrder):
1176                if i >= numberOfHMetrics:
1177                    break
1178                glyph = glyfTable[glyphName]
1179                xMin = getattr(glyph, "xMin", 0)
1180                lsbArray.append(xMin)
1181
1182        numberOfSideBearings = numGlyphs - numberOfHMetrics
1183        if hasLeftSideBearingArray:
1184            assert len(data) >= 2 * numberOfSideBearings
1185            leftSideBearingArray = array.array("h", data[: 2 * numberOfSideBearings])
1186            if sys.byteorder != "big":
1187                leftSideBearingArray.byteswap()
1188            data = data[2 * numberOfSideBearings :]
1189        else:
1190            # compute (monospaced) glyphs' leftSideBearing from their xMin
1191            leftSideBearingArray = array.array("h")
1192            for i, glyphName in enumerate(glyphOrder):
1193                if i < numberOfHMetrics:
1194                    continue
1195                glyph = glyfTable[glyphName]
1196                xMin = getattr(glyph, "xMin", 0)
1197                leftSideBearingArray.append(xMin)
1198
1199        if data:
1200            raise TTLibError("too much '%s' table data" % self.tableTag)
1201
1202        self.metrics = {}
1203        for i in range(numberOfHMetrics):
1204            glyphName = glyphOrder[i]
1205            advanceWidth, lsb = advanceWidthArray[i], lsbArray[i]
1206            self.metrics[glyphName] = (advanceWidth, lsb)
1207        lastAdvance = advanceWidthArray[-1]
1208        for i in range(numberOfSideBearings):
1209            glyphName = glyphOrder[i + numberOfHMetrics]
1210            self.metrics[glyphName] = (lastAdvance, leftSideBearingArray[i])
1211
1212    def transform(self, ttFont):
1213        glyphOrder = ttFont.getGlyphOrder()
1214        glyf = ttFont["glyf"]
1215        hhea = ttFont["hhea"]
1216        numberOfHMetrics = hhea.numberOfHMetrics
1217
1218        # check if any of the proportional glyphs has left sidebearings that
1219        # differ from their xMin bounding box values.
1220        hasLsbArray = False
1221        for i in range(numberOfHMetrics):
1222            glyphName = glyphOrder[i]
1223            lsb = self.metrics[glyphName][1]
1224            if lsb != getattr(glyf[glyphName], "xMin", 0):
1225                hasLsbArray = True
1226                break
1227
1228        # do the same for the monospaced glyphs (if any) at the end of hmtx table
1229        hasLeftSideBearingArray = False
1230        for i in range(numberOfHMetrics, len(glyphOrder)):
1231            glyphName = glyphOrder[i]
1232            lsb = self.metrics[glyphName][1]
1233            if lsb != getattr(glyf[glyphName], "xMin", 0):
1234                hasLeftSideBearingArray = True
1235                break
1236
1237        # if we need to encode both sidebearings arrays, then no transformation is
1238        # applicable, and we must use the untransformed hmtx data
1239        if hasLsbArray and hasLeftSideBearingArray:
1240            return
1241
1242        # set bit 0 and 1 when the respective arrays are _not_ present
1243        flags = 0
1244        if not hasLsbArray:
1245            flags |= 1 << 0
1246        if not hasLeftSideBearingArray:
1247            flags |= 1 << 1
1248
1249        data = struct.pack(">B", flags)
1250
1251        advanceWidthArray = array.array(
1252            "H",
1253            [
1254                self.metrics[glyphName][0]
1255                for i, glyphName in enumerate(glyphOrder)
1256                if i < numberOfHMetrics
1257            ],
1258        )
1259        if sys.byteorder != "big":
1260            advanceWidthArray.byteswap()
1261        data += advanceWidthArray.tobytes()
1262
1263        if hasLsbArray:
1264            lsbArray = array.array(
1265                "h",
1266                [
1267                    self.metrics[glyphName][1]
1268                    for i, glyphName in enumerate(glyphOrder)
1269                    if i < numberOfHMetrics
1270                ],
1271            )
1272            if sys.byteorder != "big":
1273                lsbArray.byteswap()
1274            data += lsbArray.tobytes()
1275
1276        if hasLeftSideBearingArray:
1277            leftSideBearingArray = array.array(
1278                "h",
1279                [
1280                    self.metrics[glyphOrder[i]][1]
1281                    for i in range(numberOfHMetrics, len(glyphOrder))
1282                ],
1283            )
1284            if sys.byteorder != "big":
1285                leftSideBearingArray.byteswap()
1286            data += leftSideBearingArray.tobytes()
1287
1288        return data
1289
1290
1291class WOFF2FlavorData(WOFFFlavorData):
1292    Flavor = "woff2"
1293
1294    def __init__(self, reader=None, data=None, transformedTables=None):
1295        """Data class that holds the WOFF2 header major/minor version, any
1296        metadata or private data (as bytes strings), and the set of
1297        table tags that have transformations applied (if reader is not None),
1298        or will have once the WOFF2 font is compiled.
1299
1300        Args:
1301                reader: an SFNTReader (or subclass) object to read flavor data from.
1302                data: another WOFFFlavorData object to initialise data from.
1303                transformedTables: set of strings containing table tags to be transformed.
1304
1305        Raises:
1306                ImportError if the brotli module is not installed.
1307
1308        NOTE: The 'reader' argument, on the one hand, and the 'data' and
1309        'transformedTables' arguments, on the other hand, are mutually exclusive.
1310        """
1311        if not haveBrotli:
1312            raise ImportError("No module named brotli")
1313
1314        if reader is not None:
1315            if data is not None:
1316                raise TypeError("'reader' and 'data' arguments are mutually exclusive")
1317            if transformedTables is not None:
1318                raise TypeError(
1319                    "'reader' and 'transformedTables' arguments are mutually exclusive"
1320                )
1321
1322        if transformedTables is not None and (
1323            "glyf" in transformedTables
1324            and "loca" not in transformedTables
1325            or "loca" in transformedTables
1326            and "glyf" not in transformedTables
1327        ):
1328            raise ValueError("'glyf' and 'loca' must be transformed (or not) together")
1329        super(WOFF2FlavorData, self).__init__(reader=reader)
1330        if reader:
1331            transformedTables = [
1332                tag for tag, entry in reader.tables.items() if entry.transformed
1333            ]
1334        elif data:
1335            self.majorVersion = data.majorVersion
1336            self.majorVersion = data.minorVersion
1337            self.metaData = data.metaData
1338            self.privData = data.privData
1339            if transformedTables is None and hasattr(data, "transformedTables"):
1340                transformedTables = data.transformedTables
1341
1342        if transformedTables is None:
1343            transformedTables = woff2TransformedTableTags
1344
1345        self.transformedTables = set(transformedTables)
1346
1347    def _decompress(self, rawData):
1348        return brotli.decompress(rawData)
1349
1350
1351def unpackBase128(data):
1352    r"""Read one to five bytes from UIntBase128-encoded input string, and return
1353    a tuple containing the decoded integer plus any leftover data.
1354
1355    >>> unpackBase128(b'\x3f\x00\x00') == (63, b"\x00\x00")
1356    True
1357    >>> unpackBase128(b'\x8f\xff\xff\xff\x7f')[0] == 4294967295
1358    True
1359    >>> unpackBase128(b'\x80\x80\x3f')  # doctest: +IGNORE_EXCEPTION_DETAIL
1360    Traceback (most recent call last):
1361      File "<stdin>", line 1, in ?
1362    TTLibError: UIntBase128 value must not start with leading zeros
1363    >>> unpackBase128(b'\x8f\xff\xff\xff\xff\x7f')[0]  # doctest: +IGNORE_EXCEPTION_DETAIL
1364    Traceback (most recent call last):
1365      File "<stdin>", line 1, in ?
1366    TTLibError: UIntBase128-encoded sequence is longer than 5 bytes
1367    >>> unpackBase128(b'\x90\x80\x80\x80\x00')[0]  # doctest: +IGNORE_EXCEPTION_DETAIL
1368    Traceback (most recent call last):
1369      File "<stdin>", line 1, in ?
1370    TTLibError: UIntBase128 value exceeds 2**32-1
1371    """
1372    if len(data) == 0:
1373        raise TTLibError("not enough data to unpack UIntBase128")
1374    result = 0
1375    if byteord(data[0]) == 0x80:
1376        # font must be rejected if UIntBase128 value starts with 0x80
1377        raise TTLibError("UIntBase128 value must not start with leading zeros")
1378    for i in range(woff2Base128MaxSize):
1379        if len(data) == 0:
1380            raise TTLibError("not enough data to unpack UIntBase128")
1381        code = byteord(data[0])
1382        data = data[1:]
1383        # if any of the top seven bits are set then we're about to overflow
1384        if result & 0xFE000000:
1385            raise TTLibError("UIntBase128 value exceeds 2**32-1")
1386        # set current value = old value times 128 bitwise-or (byte bitwise-and 127)
1387        result = (result << 7) | (code & 0x7F)
1388        # repeat until the most significant bit of byte is false
1389        if (code & 0x80) == 0:
1390            # return result plus left over data
1391            return result, data
1392    # make sure not to exceed the size bound
1393    raise TTLibError("UIntBase128-encoded sequence is longer than 5 bytes")
1394
1395
1396def base128Size(n):
1397    """Return the length in bytes of a UIntBase128-encoded sequence with value n.
1398
1399    >>> base128Size(0)
1400    1
1401    >>> base128Size(24567)
1402    3
1403    >>> base128Size(2**32-1)
1404    5
1405    """
1406    assert n >= 0
1407    size = 1
1408    while n >= 128:
1409        size += 1
1410        n >>= 7
1411    return size
1412
1413
1414def packBase128(n):
1415    r"""Encode unsigned integer in range 0 to 2**32-1 (inclusive) to a string of
1416    bytes using UIntBase128 variable-length encoding. Produce the shortest possible
1417    encoding.
1418
1419    >>> packBase128(63) == b"\x3f"
1420    True
1421    >>> packBase128(2**32-1) == b'\x8f\xff\xff\xff\x7f'
1422    True
1423    """
1424    if n < 0 or n >= 2**32:
1425        raise TTLibError("UIntBase128 format requires 0 <= integer <= 2**32-1")
1426    data = b""
1427    size = base128Size(n)
1428    for i in range(size):
1429        b = (n >> (7 * (size - i - 1))) & 0x7F
1430        if i < size - 1:
1431            b |= 0x80
1432        data += struct.pack("B", b)
1433    return data
1434
1435
1436def unpack255UShort(data):
1437    """Read one to three bytes from 255UInt16-encoded input string, and return a
1438    tuple containing the decoded integer plus any leftover data.
1439
1440    >>> unpack255UShort(bytechr(252))[0]
1441    252
1442
1443    Note that some numbers (e.g. 506) can have multiple encodings:
1444    >>> unpack255UShort(struct.pack("BB", 254, 0))[0]
1445    506
1446    >>> unpack255UShort(struct.pack("BB", 255, 253))[0]
1447    506
1448    >>> unpack255UShort(struct.pack("BBB", 253, 1, 250))[0]
1449    506
1450    """
1451    code = byteord(data[:1])
1452    data = data[1:]
1453    if code == 253:
1454        # read two more bytes as an unsigned short
1455        if len(data) < 2:
1456            raise TTLibError("not enough data to unpack 255UInt16")
1457        (result,) = struct.unpack(">H", data[:2])
1458        data = data[2:]
1459    elif code == 254:
1460        # read another byte, plus 253 * 2
1461        if len(data) == 0:
1462            raise TTLibError("not enough data to unpack 255UInt16")
1463        result = byteord(data[:1])
1464        result += 506
1465        data = data[1:]
1466    elif code == 255:
1467        # read another byte, plus 253
1468        if len(data) == 0:
1469            raise TTLibError("not enough data to unpack 255UInt16")
1470        result = byteord(data[:1])
1471        result += 253
1472        data = data[1:]
1473    else:
1474        # leave as is if lower than 253
1475        result = code
1476    # return result plus left over data
1477    return result, data
1478
1479
1480def pack255UShort(value):
1481    r"""Encode unsigned integer in range 0 to 65535 (inclusive) to a bytestring
1482    using 255UInt16 variable-length encoding.
1483
1484    >>> pack255UShort(252) == b'\xfc'
1485    True
1486    >>> pack255UShort(506) == b'\xfe\x00'
1487    True
1488    >>> pack255UShort(762) == b'\xfd\x02\xfa'
1489    True
1490    """
1491    if value < 0 or value > 0xFFFF:
1492        raise TTLibError("255UInt16 format requires 0 <= integer <= 65535")
1493    if value < 253:
1494        return struct.pack(">B", value)
1495    elif value < 506:
1496        return struct.pack(">BB", 255, value - 253)
1497    elif value < 762:
1498        return struct.pack(">BB", 254, value - 506)
1499    else:
1500        return struct.pack(">BH", 253, value)
1501
1502
1503def compress(input_file, output_file, transform_tables=None):
1504    """Compress OpenType font to WOFF2.
1505
1506    Args:
1507            input_file: a file path, file or file-like object (open in binary mode)
1508                    containing an OpenType font (either CFF- or TrueType-flavored).
1509            output_file: a file path, file or file-like object where to save the
1510                    compressed WOFF2 font.
1511            transform_tables: Optional[Iterable[str]]: a set of table tags for which
1512                    to enable preprocessing transformations. By default, only 'glyf'
1513                    and 'loca' tables are transformed. An empty set means disable all
1514                    transformations.
1515    """
1516    log.info("Processing %s => %s" % (input_file, output_file))
1517
1518    font = TTFont(input_file, recalcBBoxes=False, recalcTimestamp=False)
1519    font.flavor = "woff2"
1520
1521    if transform_tables is not None:
1522        font.flavorData = WOFF2FlavorData(
1523            data=font.flavorData, transformedTables=transform_tables
1524        )
1525
1526    font.save(output_file, reorderTables=False)
1527
1528
1529def decompress(input_file, output_file):
1530    """Decompress WOFF2 font to OpenType font.
1531
1532    Args:
1533            input_file: a file path, file or file-like object (open in binary mode)
1534                    containing a compressed WOFF2 font.
1535            output_file: a file path, file or file-like object where to save the
1536                    decompressed OpenType font.
1537    """
1538    log.info("Processing %s => %s" % (input_file, output_file))
1539
1540    font = TTFont(input_file, recalcBBoxes=False, recalcTimestamp=False)
1541    font.flavor = None
1542    font.flavorData = None
1543    font.save(output_file, reorderTables=True)
1544
1545
1546def main(args=None):
1547    """Compress and decompress WOFF2 fonts"""
1548    import argparse
1549    from fontTools import configLogger
1550    from fontTools.ttx import makeOutputFileName
1551
1552    class _HelpAction(argparse._HelpAction):
1553        def __call__(self, parser, namespace, values, option_string=None):
1554            subparsers_actions = [
1555                action
1556                for action in parser._actions
1557                if isinstance(action, argparse._SubParsersAction)
1558            ]
1559            for subparsers_action in subparsers_actions:
1560                for choice, subparser in subparsers_action.choices.items():
1561                    print(subparser.format_help())
1562            parser.exit()
1563
1564    class _NoGlyfTransformAction(argparse.Action):
1565        def __call__(self, parser, namespace, values, option_string=None):
1566            namespace.transform_tables.difference_update({"glyf", "loca"})
1567
1568    class _HmtxTransformAction(argparse.Action):
1569        def __call__(self, parser, namespace, values, option_string=None):
1570            namespace.transform_tables.add("hmtx")
1571
1572    parser = argparse.ArgumentParser(
1573        prog="fonttools ttLib.woff2", description=main.__doc__, add_help=False
1574    )
1575
1576    parser.add_argument(
1577        "-h", "--help", action=_HelpAction, help="show this help message and exit"
1578    )
1579
1580    parser_group = parser.add_subparsers(title="sub-commands")
1581    parser_compress = parser_group.add_parser(
1582        "compress", description="Compress a TTF or OTF font to WOFF2"
1583    )
1584    parser_decompress = parser_group.add_parser(
1585        "decompress", description="Decompress a WOFF2 font to OTF"
1586    )
1587
1588    for subparser in (parser_compress, parser_decompress):
1589        group = subparser.add_mutually_exclusive_group(required=False)
1590        group.add_argument(
1591            "-v",
1592            "--verbose",
1593            action="store_true",
1594            help="print more messages to console",
1595        )
1596        group.add_argument(
1597            "-q",
1598            "--quiet",
1599            action="store_true",
1600            help="do not print messages to console",
1601        )
1602
1603    parser_compress.add_argument(
1604        "input_file",
1605        metavar="INPUT",
1606        help="the input OpenType font (.ttf or .otf)",
1607    )
1608    parser_decompress.add_argument(
1609        "input_file",
1610        metavar="INPUT",
1611        help="the input WOFF2 font",
1612    )
1613
1614    parser_compress.add_argument(
1615        "-o",
1616        "--output-file",
1617        metavar="OUTPUT",
1618        help="the output WOFF2 font",
1619    )
1620    parser_decompress.add_argument(
1621        "-o",
1622        "--output-file",
1623        metavar="OUTPUT",
1624        help="the output OpenType font",
1625    )
1626
1627    transform_group = parser_compress.add_argument_group()
1628    transform_group.add_argument(
1629        "--no-glyf-transform",
1630        dest="transform_tables",
1631        nargs=0,
1632        action=_NoGlyfTransformAction,
1633        help="Do not transform glyf (and loca) tables",
1634    )
1635    transform_group.add_argument(
1636        "--hmtx-transform",
1637        dest="transform_tables",
1638        nargs=0,
1639        action=_HmtxTransformAction,
1640        help="Enable optional transformation for 'hmtx' table",
1641    )
1642
1643    parser_compress.set_defaults(
1644        subcommand=compress,
1645        transform_tables={"glyf", "loca"},
1646    )
1647    parser_decompress.set_defaults(subcommand=decompress)
1648
1649    options = vars(parser.parse_args(args))
1650
1651    subcommand = options.pop("subcommand", None)
1652    if not subcommand:
1653        parser.print_help()
1654        return
1655
1656    quiet = options.pop("quiet")
1657    verbose = options.pop("verbose")
1658    configLogger(
1659        level=("ERROR" if quiet else "DEBUG" if verbose else "INFO"),
1660    )
1661
1662    if not options["output_file"]:
1663        if subcommand is compress:
1664            extension = ".woff2"
1665        elif subcommand is decompress:
1666            # choose .ttf/.otf file extension depending on sfntVersion
1667            with open(options["input_file"], "rb") as f:
1668                f.seek(4)  # skip 'wOF2' signature
1669                sfntVersion = f.read(4)
1670            assert len(sfntVersion) == 4, "not enough data"
1671            extension = ".otf" if sfntVersion == b"OTTO" else ".ttf"
1672        else:
1673            raise AssertionError(subcommand)
1674        options["output_file"] = makeOutputFileName(
1675            options["input_file"], outputDir=None, extension=extension
1676        )
1677
1678    try:
1679        subcommand(**options)
1680    except TTLibError as e:
1681        parser.error(e)
1682
1683
1684if __name__ == "__main__":
1685    sys.exit(main())
1686