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