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