xref: /aosp_15_r20/external/fonttools/Lib/fontTools/ttLib/sfnt.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
1"""ttLib/sfnt.py -- low-level module to deal with the sfnt file format.
2
3Defines two public classes:
4	SFNTReader
5	SFNTWriter
6
7(Normally you don't have to use these classes explicitly; they are
8used automatically by ttLib.TTFont.)
9
10The reading and writing of sfnt files is separated in two distinct
11classes, since whenever the number of tables changes or whenever
12a table's length changes you need to rewrite the whole file anyway.
13"""
14
15from io import BytesIO
16from types import SimpleNamespace
17from fontTools.misc.textTools import Tag
18from fontTools.misc import sstruct
19from fontTools.ttLib import TTLibError, TTLibFileIsCollectionError
20import struct
21from collections import OrderedDict
22import logging
23
24
25log = logging.getLogger(__name__)
26
27
28class SFNTReader(object):
29    def __new__(cls, *args, **kwargs):
30        """Return an instance of the SFNTReader sub-class which is compatible
31        with the input file type.
32        """
33        if args and cls is SFNTReader:
34            infile = args[0]
35            infile.seek(0)
36            sfntVersion = Tag(infile.read(4))
37            infile.seek(0)
38            if sfntVersion == "wOF2":
39                # return new WOFF2Reader object
40                from fontTools.ttLib.woff2 import WOFF2Reader
41
42                return object.__new__(WOFF2Reader)
43        # return default object
44        return object.__new__(cls)
45
46    def __init__(self, file, checkChecksums=0, fontNumber=-1):
47        self.file = file
48        self.checkChecksums = checkChecksums
49
50        self.flavor = None
51        self.flavorData = None
52        self.DirectoryEntry = SFNTDirectoryEntry
53        self.file.seek(0)
54        self.sfntVersion = self.file.read(4)
55        self.file.seek(0)
56        if self.sfntVersion == b"ttcf":
57            header = readTTCHeader(self.file)
58            numFonts = header.numFonts
59            if not 0 <= fontNumber < numFonts:
60                raise TTLibFileIsCollectionError(
61                    "specify a font number between 0 and %d (inclusive)"
62                    % (numFonts - 1)
63                )
64            self.numFonts = numFonts
65            self.file.seek(header.offsetTable[fontNumber])
66            data = self.file.read(sfntDirectorySize)
67            if len(data) != sfntDirectorySize:
68                raise TTLibError("Not a Font Collection (not enough data)")
69            sstruct.unpack(sfntDirectoryFormat, data, self)
70        elif self.sfntVersion == b"wOFF":
71            self.flavor = "woff"
72            self.DirectoryEntry = WOFFDirectoryEntry
73            data = self.file.read(woffDirectorySize)
74            if len(data) != woffDirectorySize:
75                raise TTLibError("Not a WOFF font (not enough data)")
76            sstruct.unpack(woffDirectoryFormat, data, self)
77        else:
78            data = self.file.read(sfntDirectorySize)
79            if len(data) != sfntDirectorySize:
80                raise TTLibError("Not a TrueType or OpenType font (not enough data)")
81            sstruct.unpack(sfntDirectoryFormat, data, self)
82        self.sfntVersion = Tag(self.sfntVersion)
83
84        if self.sfntVersion not in ("\x00\x01\x00\x00", "OTTO", "true"):
85            raise TTLibError("Not a TrueType or OpenType font (bad sfntVersion)")
86        tables = {}
87        for i in range(self.numTables):
88            entry = self.DirectoryEntry()
89            entry.fromFile(self.file)
90            tag = Tag(entry.tag)
91            tables[tag] = entry
92        self.tables = OrderedDict(sorted(tables.items(), key=lambda i: i[1].offset))
93
94        # Load flavor data if any
95        if self.flavor == "woff":
96            self.flavorData = WOFFFlavorData(self)
97
98    def has_key(self, tag):
99        return tag in self.tables
100
101    __contains__ = has_key
102
103    def keys(self):
104        return self.tables.keys()
105
106    def __getitem__(self, tag):
107        """Fetch the raw table data."""
108        entry = self.tables[Tag(tag)]
109        data = entry.loadData(self.file)
110        if self.checkChecksums:
111            if tag == "head":
112                # Beh: we have to special-case the 'head' table.
113                checksum = calcChecksum(data[:8] + b"\0\0\0\0" + data[12:])
114            else:
115                checksum = calcChecksum(data)
116            if self.checkChecksums > 1:
117                # Be obnoxious, and barf when it's wrong
118                assert checksum == entry.checkSum, "bad checksum for '%s' table" % tag
119            elif checksum != entry.checkSum:
120                # Be friendly, and just log a warning.
121                log.warning("bad checksum for '%s' table", tag)
122        return data
123
124    def __delitem__(self, tag):
125        del self.tables[Tag(tag)]
126
127    def close(self):
128        self.file.close()
129
130    # We define custom __getstate__ and __setstate__ to make SFNTReader pickle-able
131    # and deepcopy-able. When a TTFont is loaded as lazy=True, SFNTReader holds a
132    # reference to an external file object which is not pickleable. So in __getstate__
133    # we store the file name and current position, and in __setstate__ we reopen the
134    # same named file after unpickling.
135
136    def __getstate__(self):
137        if isinstance(self.file, BytesIO):
138            # BytesIO is already pickleable, return the state unmodified
139            return self.__dict__
140
141        # remove unpickleable file attribute, and only store its name and pos
142        state = self.__dict__.copy()
143        del state["file"]
144        state["_filename"] = self.file.name
145        state["_filepos"] = self.file.tell()
146        return state
147
148    def __setstate__(self, state):
149        if "file" not in state:
150            self.file = open(state.pop("_filename"), "rb")
151            self.file.seek(state.pop("_filepos"))
152        self.__dict__.update(state)
153
154
155# default compression level for WOFF 1.0 tables and metadata
156ZLIB_COMPRESSION_LEVEL = 6
157
158# if set to True, use zopfli instead of zlib for compressing WOFF 1.0.
159# The Python bindings are available at https://pypi.python.org/pypi/zopfli
160USE_ZOPFLI = False
161
162# mapping between zlib's compression levels and zopfli's 'numiterations'.
163# Use lower values for files over several MB in size or it will be too slow
164ZOPFLI_LEVELS = {
165    # 0: 0,  # can't do 0 iterations...
166    1: 1,
167    2: 3,
168    3: 5,
169    4: 8,
170    5: 10,
171    6: 15,
172    7: 25,
173    8: 50,
174    9: 100,
175}
176
177
178def compress(data, level=ZLIB_COMPRESSION_LEVEL):
179    """Compress 'data' to Zlib format. If 'USE_ZOPFLI' variable is True,
180    zopfli is used instead of the zlib module.
181    The compression 'level' must be between 0 and 9. 1 gives best speed,
182    9 gives best compression (0 gives no compression at all).
183    The default value is a compromise between speed and compression (6).
184    """
185    if not (0 <= level <= 9):
186        raise ValueError("Bad compression level: %s" % level)
187    if not USE_ZOPFLI or level == 0:
188        from zlib import compress
189
190        return compress(data, level)
191    else:
192        from zopfli.zlib import compress
193
194        return compress(data, numiterations=ZOPFLI_LEVELS[level])
195
196
197class SFNTWriter(object):
198    def __new__(cls, *args, **kwargs):
199        """Return an instance of the SFNTWriter sub-class which is compatible
200        with the specified 'flavor'.
201        """
202        flavor = None
203        if kwargs and "flavor" in kwargs:
204            flavor = kwargs["flavor"]
205        elif args and len(args) > 3:
206            flavor = args[3]
207        if cls is SFNTWriter:
208            if flavor == "woff2":
209                # return new WOFF2Writer object
210                from fontTools.ttLib.woff2 import WOFF2Writer
211
212                return object.__new__(WOFF2Writer)
213        # return default object
214        return object.__new__(cls)
215
216    def __init__(
217        self,
218        file,
219        numTables,
220        sfntVersion="\000\001\000\000",
221        flavor=None,
222        flavorData=None,
223    ):
224        self.file = file
225        self.numTables = numTables
226        self.sfntVersion = Tag(sfntVersion)
227        self.flavor = flavor
228        self.flavorData = flavorData
229
230        if self.flavor == "woff":
231            self.directoryFormat = woffDirectoryFormat
232            self.directorySize = woffDirectorySize
233            self.DirectoryEntry = WOFFDirectoryEntry
234
235            self.signature = "wOFF"
236
237            # to calculate WOFF checksum adjustment, we also need the original SFNT offsets
238            self.origNextTableOffset = (
239                sfntDirectorySize + numTables * sfntDirectoryEntrySize
240            )
241        else:
242            assert not self.flavor, "Unknown flavor '%s'" % self.flavor
243            self.directoryFormat = sfntDirectoryFormat
244            self.directorySize = sfntDirectorySize
245            self.DirectoryEntry = SFNTDirectoryEntry
246
247            from fontTools.ttLib import getSearchRange
248
249            self.searchRange, self.entrySelector, self.rangeShift = getSearchRange(
250                numTables, 16
251            )
252
253        self.directoryOffset = self.file.tell()
254        self.nextTableOffset = (
255            self.directoryOffset
256            + self.directorySize
257            + numTables * self.DirectoryEntry.formatSize
258        )
259        # clear out directory area
260        self.file.seek(self.nextTableOffset)
261        # make sure we're actually where we want to be. (old cStringIO bug)
262        self.file.write(b"\0" * (self.nextTableOffset - self.file.tell()))
263        self.tables = OrderedDict()
264
265    def setEntry(self, tag, entry):
266        if tag in self.tables:
267            raise TTLibError("cannot rewrite '%s' table" % tag)
268
269        self.tables[tag] = entry
270
271    def __setitem__(self, tag, data):
272        """Write raw table data to disk."""
273        if tag in self.tables:
274            raise TTLibError("cannot rewrite '%s' table" % tag)
275
276        entry = self.DirectoryEntry()
277        entry.tag = tag
278        entry.offset = self.nextTableOffset
279        if tag == "head":
280            entry.checkSum = calcChecksum(data[:8] + b"\0\0\0\0" + data[12:])
281            self.headTable = data
282            entry.uncompressed = True
283        else:
284            entry.checkSum = calcChecksum(data)
285        entry.saveData(self.file, data)
286
287        if self.flavor == "woff":
288            entry.origOffset = self.origNextTableOffset
289            self.origNextTableOffset += (entry.origLength + 3) & ~3
290
291        self.nextTableOffset = self.nextTableOffset + ((entry.length + 3) & ~3)
292        # Add NUL bytes to pad the table data to a 4-byte boundary.
293        # Don't depend on f.seek() as we need to add the padding even if no
294        # subsequent write follows (seek is lazy), ie. after the final table
295        # in the font.
296        self.file.write(b"\0" * (self.nextTableOffset - self.file.tell()))
297        assert self.nextTableOffset == self.file.tell()
298
299        self.setEntry(tag, entry)
300
301    def __getitem__(self, tag):
302        return self.tables[tag]
303
304    def close(self):
305        """All tables must have been written to disk. Now write the
306        directory.
307        """
308        tables = sorted(self.tables.items())
309        if len(tables) != self.numTables:
310            raise TTLibError(
311                "wrong number of tables; expected %d, found %d"
312                % (self.numTables, len(tables))
313            )
314
315        if self.flavor == "woff":
316            self.signature = b"wOFF"
317            self.reserved = 0
318
319            self.totalSfntSize = 12
320            self.totalSfntSize += 16 * len(tables)
321            for tag, entry in tables:
322                self.totalSfntSize += (entry.origLength + 3) & ~3
323
324            data = self.flavorData if self.flavorData else WOFFFlavorData()
325            if data.majorVersion is not None and data.minorVersion is not None:
326                self.majorVersion = data.majorVersion
327                self.minorVersion = data.minorVersion
328            else:
329                if hasattr(self, "headTable"):
330                    self.majorVersion, self.minorVersion = struct.unpack(
331                        ">HH", self.headTable[4:8]
332                    )
333                else:
334                    self.majorVersion = self.minorVersion = 0
335            if data.metaData:
336                self.metaOrigLength = len(data.metaData)
337                self.file.seek(0, 2)
338                self.metaOffset = self.file.tell()
339                compressedMetaData = compress(data.metaData)
340                self.metaLength = len(compressedMetaData)
341                self.file.write(compressedMetaData)
342            else:
343                self.metaOffset = self.metaLength = self.metaOrigLength = 0
344            if data.privData:
345                self.file.seek(0, 2)
346                off = self.file.tell()
347                paddedOff = (off + 3) & ~3
348                self.file.write(b"\0" * (paddedOff - off))
349                self.privOffset = self.file.tell()
350                self.privLength = len(data.privData)
351                self.file.write(data.privData)
352            else:
353                self.privOffset = self.privLength = 0
354
355            self.file.seek(0, 2)
356            self.length = self.file.tell()
357
358        else:
359            assert not self.flavor, "Unknown flavor '%s'" % self.flavor
360            pass
361
362        directory = sstruct.pack(self.directoryFormat, self)
363
364        self.file.seek(self.directoryOffset + self.directorySize)
365        seenHead = 0
366        for tag, entry in tables:
367            if tag == "head":
368                seenHead = 1
369            directory = directory + entry.toString()
370        if seenHead:
371            self.writeMasterChecksum(directory)
372        self.file.seek(self.directoryOffset)
373        self.file.write(directory)
374
375    def _calcMasterChecksum(self, directory):
376        # calculate checkSumAdjustment
377        tags = list(self.tables.keys())
378        checksums = []
379        for i in range(len(tags)):
380            checksums.append(self.tables[tags[i]].checkSum)
381
382        if self.DirectoryEntry != SFNTDirectoryEntry:
383            # Create a SFNT directory for checksum calculation purposes
384            from fontTools.ttLib import getSearchRange
385
386            self.searchRange, self.entrySelector, self.rangeShift = getSearchRange(
387                self.numTables, 16
388            )
389            directory = sstruct.pack(sfntDirectoryFormat, self)
390            tables = sorted(self.tables.items())
391            for tag, entry in tables:
392                sfntEntry = SFNTDirectoryEntry()
393                sfntEntry.tag = entry.tag
394                sfntEntry.checkSum = entry.checkSum
395                sfntEntry.offset = entry.origOffset
396                sfntEntry.length = entry.origLength
397                directory = directory + sfntEntry.toString()
398
399        directory_end = sfntDirectorySize + len(self.tables) * sfntDirectoryEntrySize
400        assert directory_end == len(directory)
401
402        checksums.append(calcChecksum(directory))
403        checksum = sum(checksums) & 0xFFFFFFFF
404        # BiboAfba!
405        checksumadjustment = (0xB1B0AFBA - checksum) & 0xFFFFFFFF
406        return checksumadjustment
407
408    def writeMasterChecksum(self, directory):
409        checksumadjustment = self._calcMasterChecksum(directory)
410        # write the checksum to the file
411        self.file.seek(self.tables["head"].offset + 8)
412        self.file.write(struct.pack(">L", checksumadjustment))
413
414    def reordersTables(self):
415        return False
416
417
418# -- sfnt directory helpers and cruft
419
420ttcHeaderFormat = """
421		> # big endian
422		TTCTag:                  4s # "ttcf"
423		Version:                 L  # 0x00010000 or 0x00020000
424		numFonts:                L  # number of fonts
425		# OffsetTable[numFonts]: L  # array with offsets from beginning of file
426		# ulDsigTag:             L  # version 2.0 only
427		# ulDsigLength:          L  # version 2.0 only
428		# ulDsigOffset:          L  # version 2.0 only
429"""
430
431ttcHeaderSize = sstruct.calcsize(ttcHeaderFormat)
432
433sfntDirectoryFormat = """
434		> # big endian
435		sfntVersion:    4s
436		numTables:      H    # number of tables
437		searchRange:    H    # (max2 <= numTables)*16
438		entrySelector:  H    # log2(max2 <= numTables)
439		rangeShift:     H    # numTables*16-searchRange
440"""
441
442sfntDirectorySize = sstruct.calcsize(sfntDirectoryFormat)
443
444sfntDirectoryEntryFormat = """
445		> # big endian
446		tag:            4s
447		checkSum:       L
448		offset:         L
449		length:         L
450"""
451
452sfntDirectoryEntrySize = sstruct.calcsize(sfntDirectoryEntryFormat)
453
454woffDirectoryFormat = """
455		> # big endian
456		signature:      4s   # "wOFF"
457		sfntVersion:    4s
458		length:         L    # total woff file size
459		numTables:      H    # number of tables
460		reserved:       H    # set to 0
461		totalSfntSize:  L    # uncompressed size
462		majorVersion:   H    # major version of WOFF file
463		minorVersion:   H    # minor version of WOFF file
464		metaOffset:     L    # offset to metadata block
465		metaLength:     L    # length of compressed metadata
466		metaOrigLength: L    # length of uncompressed metadata
467		privOffset:     L    # offset to private data block
468		privLength:     L    # length of private data block
469"""
470
471woffDirectorySize = sstruct.calcsize(woffDirectoryFormat)
472
473woffDirectoryEntryFormat = """
474		> # big endian
475		tag:            4s
476		offset:         L
477		length:         L    # compressed length
478		origLength:     L    # original length
479		checkSum:       L    # original checksum
480"""
481
482woffDirectoryEntrySize = sstruct.calcsize(woffDirectoryEntryFormat)
483
484
485class DirectoryEntry(object):
486    def __init__(self):
487        self.uncompressed = False  # if True, always embed entry raw
488
489    def fromFile(self, file):
490        sstruct.unpack(self.format, file.read(self.formatSize), self)
491
492    def fromString(self, str):
493        sstruct.unpack(self.format, str, self)
494
495    def toString(self):
496        return sstruct.pack(self.format, self)
497
498    def __repr__(self):
499        if hasattr(self, "tag"):
500            return "<%s '%s' at %x>" % (self.__class__.__name__, self.tag, id(self))
501        else:
502            return "<%s at %x>" % (self.__class__.__name__, id(self))
503
504    def loadData(self, file):
505        file.seek(self.offset)
506        data = file.read(self.length)
507        assert len(data) == self.length
508        if hasattr(self.__class__, "decodeData"):
509            data = self.decodeData(data)
510        return data
511
512    def saveData(self, file, data):
513        if hasattr(self.__class__, "encodeData"):
514            data = self.encodeData(data)
515        self.length = len(data)
516        file.seek(self.offset)
517        file.write(data)
518
519    def decodeData(self, rawData):
520        return rawData
521
522    def encodeData(self, data):
523        return data
524
525
526class SFNTDirectoryEntry(DirectoryEntry):
527    format = sfntDirectoryEntryFormat
528    formatSize = sfntDirectoryEntrySize
529
530
531class WOFFDirectoryEntry(DirectoryEntry):
532    format = woffDirectoryEntryFormat
533    formatSize = woffDirectoryEntrySize
534
535    def __init__(self):
536        super(WOFFDirectoryEntry, self).__init__()
537        # With fonttools<=3.1.2, the only way to set a different zlib
538        # compression level for WOFF directory entries was to set the class
539        # attribute 'zlibCompressionLevel'. This is now replaced by a globally
540        # defined `ZLIB_COMPRESSION_LEVEL`, which is also applied when
541        # compressing the metadata. For backward compatibility, we still
542        # use the class attribute if it was already set.
543        if not hasattr(WOFFDirectoryEntry, "zlibCompressionLevel"):
544            self.zlibCompressionLevel = ZLIB_COMPRESSION_LEVEL
545
546    def decodeData(self, rawData):
547        import zlib
548
549        if self.length == self.origLength:
550            data = rawData
551        else:
552            assert self.length < self.origLength
553            data = zlib.decompress(rawData)
554            assert len(data) == self.origLength
555        return data
556
557    def encodeData(self, data):
558        self.origLength = len(data)
559        if not self.uncompressed:
560            compressedData = compress(data, self.zlibCompressionLevel)
561        if self.uncompressed or len(compressedData) >= self.origLength:
562            # Encode uncompressed
563            rawData = data
564            self.length = self.origLength
565        else:
566            rawData = compressedData
567            self.length = len(rawData)
568        return rawData
569
570
571class WOFFFlavorData:
572    Flavor = "woff"
573
574    def __init__(self, reader=None):
575        self.majorVersion = None
576        self.minorVersion = None
577        self.metaData = None
578        self.privData = None
579        if reader:
580            self.majorVersion = reader.majorVersion
581            self.minorVersion = reader.minorVersion
582            if reader.metaLength:
583                reader.file.seek(reader.metaOffset)
584                rawData = reader.file.read(reader.metaLength)
585                assert len(rawData) == reader.metaLength
586                data = self._decompress(rawData)
587                assert len(data) == reader.metaOrigLength
588                self.metaData = data
589            if reader.privLength:
590                reader.file.seek(reader.privOffset)
591                data = reader.file.read(reader.privLength)
592                assert len(data) == reader.privLength
593                self.privData = data
594
595    def _decompress(self, rawData):
596        import zlib
597
598        return zlib.decompress(rawData)
599
600
601def calcChecksum(data):
602    """Calculate the checksum for an arbitrary block of data.
603
604    If the data length is not a multiple of four, it assumes
605    it is to be padded with null byte.
606
607            >>> print(calcChecksum(b"abcd"))
608            1633837924
609            >>> print(calcChecksum(b"abcdxyz"))
610            3655064932
611    """
612    remainder = len(data) % 4
613    if remainder:
614        data += b"\0" * (4 - remainder)
615    value = 0
616    blockSize = 4096
617    assert blockSize % 4 == 0
618    for i in range(0, len(data), blockSize):
619        block = data[i : i + blockSize]
620        longs = struct.unpack(">%dL" % (len(block) // 4), block)
621        value = (value + sum(longs)) & 0xFFFFFFFF
622    return value
623
624
625def readTTCHeader(file):
626    file.seek(0)
627    data = file.read(ttcHeaderSize)
628    if len(data) != ttcHeaderSize:
629        raise TTLibError("Not a Font Collection (not enough data)")
630    self = SimpleNamespace()
631    sstruct.unpack(ttcHeaderFormat, data, self)
632    if self.TTCTag != "ttcf":
633        raise TTLibError("Not a Font Collection")
634    assert self.Version == 0x00010000 or self.Version == 0x00020000, (
635        "unrecognized TTC version 0x%08x" % self.Version
636    )
637    self.offsetTable = struct.unpack(
638        ">%dL" % self.numFonts, file.read(self.numFonts * 4)
639    )
640    if self.Version == 0x00020000:
641        pass  # ignoring version 2.0 signatures
642    return self
643
644
645def writeTTCHeader(file, numFonts):
646    self = SimpleNamespace()
647    self.TTCTag = "ttcf"
648    self.Version = 0x00010000
649    self.numFonts = numFonts
650    file.seek(0)
651    file.write(sstruct.pack(ttcHeaderFormat, self))
652    offset = file.tell()
653    file.write(struct.pack(">%dL" % self.numFonts, *([0] * self.numFonts)))
654    return offset
655
656
657if __name__ == "__main__":
658    import sys
659    import doctest
660
661    sys.exit(doctest.testmod().failed)
662