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