sfnt.py revision 1ae29591efbb29492ce05378909ccf4028d7c1ee
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 18import struct 19 20 21class SFNTReader(object): 22 23 def __init__(self, file, checkChecksums=1, fontNumber=-1): 24 self.file = file 25 self.checkChecksums = checkChecksums 26 27 self.flavor = None 28 self.flavorData = None 29 self.DirectoryEntry = SFNTDirectoryEntry 30 self.sfntVersion = self.file.read(4) 31 self.file.seek(0) 32 if self.sfntVersion == b"ttcf": 33 sstruct.unpack(ttcHeaderFormat, self.file.read(ttcHeaderSize), self) 34 assert self.Version == 0x00010000 or self.Version == 0x00020000, "unrecognized TTC version 0x%08x" % self.Version 35 if not 0 <= fontNumber < self.numFonts: 36 from fontTools import ttLib 37 raise ttLib.TTLibError("specify a font number between 0 and %d (inclusive)" % (self.numFonts - 1)) 38 offsetTable = struct.unpack(">%dL" % self.numFonts, self.file.read(self.numFonts * 4)) 39 if self.Version == 0x00020000: 40 pass # ignoring version 2.0 signatures 41 self.file.seek(offsetTable[fontNumber]) 42 sstruct.unpack(sfntDirectoryFormat, self.file.read(sfntDirectorySize), self) 43 elif self.sfntVersion == b"wOFF": 44 self.flavor = "woff" 45 self.DirectoryEntry = WOFFDirectoryEntry 46 sstruct.unpack(woffDirectoryFormat, self.file.read(woffDirectorySize), self) 47 else: 48 sstruct.unpack(sfntDirectoryFormat, self.file.read(sfntDirectorySize), self) 49 self.sfntVersion = Tag(self.sfntVersion) 50 51 if self.sfntVersion not in ("\x00\x01\x00\x00", "OTTO", "true"): 52 from fontTools import ttLib 53 raise ttLib.TTLibError("Not a TrueType or OpenType font (bad sfntVersion)") 54 self.tables = {} 55 for i in range(self.numTables): 56 entry = self.DirectoryEntry() 57 entry.fromFile(self.file) 58 if entry.length > 0: 59 self.tables[Tag(entry.tag)] = entry 60 else: 61 # Ignore zero-length tables. This doesn't seem to be documented, 62 # yet it's apparently how the Windows TT rasterizer behaves. 63 # Besides, at least one font has been sighted which actually 64 # *has* a zero-length table. 65 pass 66 67 # Load flavor data if any 68 if self.flavor == "woff": 69 self.flavorData = WOFFFlavorData(self) 70 71 def has_key(self, tag): 72 return tag in self.tables 73 74 __contains__ = has_key 75 76 def keys(self): 77 return self.tables.keys() 78 79 def __getitem__(self, tag): 80 """Fetch the raw table data.""" 81 entry = self.tables[Tag(tag)] 82 data = entry.loadData (self.file) 83 if self.checkChecksums: 84 if tag == 'head': 85 # Beh: we have to special-case the 'head' table. 86 checksum = calcChecksum(data[:8] + b'\0\0\0\0' + data[12:]) 87 else: 88 checksum = calcChecksum(data) 89 if self.checkChecksums > 1: 90 # Be obnoxious, and barf when it's wrong 91 assert checksum == entry.checksum, "bad checksum for '%s' table" % tag 92 elif checksum != entry.checkSum: 93 # Be friendly, and just print a warning. 94 print("bad checksum for '%s' table" % tag) 95 return data 96 97 def __delitem__(self, tag): 98 del self.tables[Tag(tag)] 99 100 def close(self): 101 self.file.close() 102 103 104class SFNTWriter(object): 105 106 def __init__(self, file, numTables, sfntVersion="\000\001\000\000", 107 flavor=None, flavorData=None): 108 self.file = file 109 self.numTables = numTables 110 self.sfntVersion = Tag(sfntVersion) 111 self.flavor = flavor 112 self.flavorData = flavorData 113 114 if self.flavor == "woff": 115 self.directoryFormat = woffDirectoryFormat 116 self.directorySize = woffDirectorySize 117 self.DirectoryEntry = WOFFDirectoryEntry 118 119 self.signature = "wOFF" 120 else: 121 assert not self.flavor, "Unknown flavor '%s'" % self.flavor 122 self.directoryFormat = sfntDirectoryFormat 123 self.directorySize = sfntDirectorySize 124 self.DirectoryEntry = SFNTDirectoryEntry 125 126 self.searchRange, self.entrySelector, self.rangeShift = getSearchRange(numTables) 127 128 self.nextTableOffset = self.directorySize + numTables * self.DirectoryEntry.formatSize 129 # clear out directory area 130 self.file.seek(self.nextTableOffset) 131 # make sure we're actually where we want to be. (old cStringIO bug) 132 self.file.write(b'\0' * (self.nextTableOffset - self.file.tell())) 133 self.tables = {} 134 135 def __setitem__(self, tag, data): 136 """Write raw table data to disk.""" 137 reuse = False 138 if tag in self.tables: 139 # We've written this table to file before. If the length 140 # of the data is still the same, we allow overwriting it. 141 entry = self.tables[tag] 142 assert not hasattr(entry.__class__, 'encodeData') 143 if len(data) != entry.length: 144 from fontTools import ttLib 145 raise ttLib.TTLibError("cannot rewrite '%s' table: length does not match directory entry" % tag) 146 reuse = True 147 else: 148 entry = self.DirectoryEntry() 149 entry.tag = tag 150 151 if tag == 'head': 152 entry.checkSum = calcChecksum(data[:8] + b'\0\0\0\0' + data[12:]) 153 self.headTable = data 154 entry.uncompressed = True 155 else: 156 entry.checkSum = calcChecksum(data) 157 158 entry.offset = self.nextTableOffset 159 entry.saveData (self.file, data) 160 161 if not reuse: 162 self.nextTableOffset = self.nextTableOffset + ((entry.length + 3) & ~3) 163 164 # Add NUL bytes to pad the table data to a 4-byte boundary. 165 # Don't depend on f.seek() as we need to add the padding even if no 166 # subsequent write follows (seek is lazy), ie. after the final table 167 # in the font. 168 self.file.write(b'\0' * (self.nextTableOffset - self.file.tell())) 169 assert self.nextTableOffset == self.file.tell() 170 171 self.tables[tag] = entry 172 173 def close(self): 174 """All tables must have been written to disk. Now write the 175 directory. 176 """ 177 tables = sorted(self.tables.items()) 178 if len(tables) != self.numTables: 179 from fontTools import ttLib 180 raise ttLib.TTLibError("wrong number of tables; expected %d, found %d" % (self.numTables, len(tables))) 181 182 if self.flavor == "woff": 183 self.signature = b"wOFF" 184 self.reserved = 0 185 186 self.totalSfntSize = 12 187 self.totalSfntSize += 16 * len(tables) 188 for tag, entry in tables: 189 self.totalSfntSize += (entry.origLength + 3) & ~3 190 191 data = self.flavorData if self.flavorData else WOFFFlavorData() 192 if data.majorVersion is not None and data.minorVersion is not None: 193 self.majorVersion = data.majorVersion 194 self.minorVersion = data.minorVersion 195 else: 196 if hasattr(self, 'headTable'): 197 self.majorVersion, self.minorVersion = struct.unpack(">HH", self.headTable[4:8]) 198 else: 199 self.majorVersion = self.minorVersion = 0 200 if data.metaData: 201 self.metaOrigLength = len(data.metaData) 202 self.file.seek(0,2) 203 self.metaOffset = self.file.tell() 204 import zlib 205 compressedMetaData = zlib.compress(data.metaData) 206 self.metaLength = len(compressedMetaData) 207 self.file.write(compressedMetaData) 208 else: 209 self.metaOffset = self.metaLength = self.metaOrigLength = 0 210 if data.privData: 211 self.file.seek(0,2) 212 off = self.file.tell() 213 paddedOff = (off + 3) & ~3 214 self.file.write('\0' * (paddedOff - off)) 215 self.privOffset = self.file.tell() 216 self.privLength = len(data.privData) 217 self.file.write(data.privData) 218 else: 219 self.privOffset = self.privLength = 0 220 221 self.file.seek(0,2) 222 self.length = self.file.tell() 223 224 else: 225 assert not self.flavor, "Unknown flavor '%s'" % self.flavor 226 pass 227 228 directory = sstruct.pack(self.directoryFormat, self) 229 230 self.file.seek(self.directorySize) 231 seenHead = 0 232 for tag, entry in tables: 233 if tag == "head": 234 seenHead = 1 235 directory = directory + entry.toString() 236 if seenHead: 237 self.writeMasterChecksum(directory) 238 self.file.seek(0) 239 self.file.write(directory) 240 241 def _calcMasterChecksum(self, directory): 242 # calculate checkSumAdjustment 243 tags = list(self.tables.keys()) 244 checksums = [] 245 for i in range(len(tags)): 246 checksums.append(self.tables[tags[i]].checkSum) 247 248 # TODO(behdad) I'm fairly sure the checksum for woff is not working correctly. 249 # Haven't debugged. 250 if self.DirectoryEntry != SFNTDirectoryEntry: 251 # Create a SFNT directory for checksum calculation purposes 252 self.searchRange, self.entrySelector, self.rangeShift = getSearchRange(self.numTables) 253 directory = sstruct.pack(sfntDirectoryFormat, self) 254 tables = sorted(self.tables.items()) 255 for tag, entry in tables: 256 sfntEntry = SFNTDirectoryEntry() 257 for item in ['tag', 'checkSum', 'offset', 'length']: 258 setattr(sfntEntry, item, getattr(entry, item)) 259 directory = directory + sfntEntry.toString() 260 261 directory_end = sfntDirectorySize + len(self.tables) * sfntDirectoryEntrySize 262 assert directory_end == len(directory) 263 264 checksums.append(calcChecksum(directory)) 265 checksum = sum(checksums) & 0xffffffff 266 # BiboAfba! 267 checksumadjustment = (0xB1B0AFBA - checksum) & 0xffffffff 268 return checksumadjustment 269 270 def writeMasterChecksum(self, directory): 271 checksumadjustment = self._calcMasterChecksum(directory) 272 # write the checksum to the file 273 self.file.seek(self.tables['head'].offset + 8) 274 self.file.write(struct.pack(">L", checksumadjustment)) 275 276 277# -- sfnt directory helpers and cruft 278 279ttcHeaderFormat = """ 280 > # big endian 281 TTCTag: 4s # "ttcf" 282 Version: L # 0x00010000 or 0x00020000 283 numFonts: L # number of fonts 284 # OffsetTable[numFonts]: L # array with offsets from beginning of file 285 # ulDsigTag: L # version 2.0 only 286 # ulDsigLength: L # version 2.0 only 287 # ulDsigOffset: L # version 2.0 only 288""" 289 290ttcHeaderSize = sstruct.calcsize(ttcHeaderFormat) 291 292sfntDirectoryFormat = """ 293 > # big endian 294 sfntVersion: 4s 295 numTables: H # number of tables 296 searchRange: H # (max2 <= numTables)*16 297 entrySelector: H # log2(max2 <= numTables) 298 rangeShift: H # numTables*16-searchRange 299""" 300 301sfntDirectorySize = sstruct.calcsize(sfntDirectoryFormat) 302 303sfntDirectoryEntryFormat = """ 304 > # big endian 305 tag: 4s 306 checkSum: L 307 offset: L 308 length: L 309""" 310 311sfntDirectoryEntrySize = sstruct.calcsize(sfntDirectoryEntryFormat) 312 313woffDirectoryFormat = """ 314 > # big endian 315 signature: 4s # "wOFF" 316 sfntVersion: 4s 317 length: L # total woff file size 318 numTables: H # number of tables 319 reserved: H # set to 0 320 totalSfntSize: L # uncompressed size 321 majorVersion: H # major version of WOFF file 322 minorVersion: H # minor version of WOFF file 323 metaOffset: L # offset to metadata block 324 metaLength: L # length of compressed metadata 325 metaOrigLength: L # length of uncompressed metadata 326 privOffset: L # offset to private data block 327 privLength: L # length of private data block 328""" 329 330woffDirectorySize = sstruct.calcsize(woffDirectoryFormat) 331 332woffDirectoryEntryFormat = """ 333 > # big endian 334 tag: 4s 335 offset: L 336 length: L # compressed length 337 origLength: L # original length 338 checkSum: L # original checksum 339""" 340 341woffDirectoryEntrySize = sstruct.calcsize(woffDirectoryEntryFormat) 342 343 344class DirectoryEntry(object): 345 346 def __init__(self): 347 self.uncompressed = False # if True, always embed entry raw 348 349 def fromFile(self, file): 350 sstruct.unpack(self.format, file.read(self.formatSize), self) 351 352 def fromString(self, str): 353 sstruct.unpack(self.format, str, self) 354 355 def toString(self): 356 return sstruct.pack(self.format, self) 357 358 def __repr__(self): 359 if hasattr(self, "tag"): 360 return "<%s '%s' at %x>" % (self.__class__.__name__, self.tag, id(self)) 361 else: 362 return "<%s at %x>" % (self.__class__.__name__, id(self)) 363 364 def loadData(self, file): 365 file.seek(self.offset) 366 data = file.read(self.length) 367 assert len(data) == self.length 368 if hasattr(self.__class__, 'decodeData'): 369 data = self.decodeData(data) 370 return data 371 372 def saveData(self, file, data): 373 if hasattr(self.__class__, 'encodeData'): 374 data = self.encodeData(data) 375 self.length = len(data) 376 file.seek(self.offset) 377 file.write(data) 378 379 def decodeData(self, rawData): 380 return rawData 381 382 def encodeData(self, data): 383 return data 384 385class SFNTDirectoryEntry(DirectoryEntry): 386 387 format = sfntDirectoryEntryFormat 388 formatSize = sfntDirectoryEntrySize 389 390class WOFFDirectoryEntry(DirectoryEntry): 391 392 format = woffDirectoryEntryFormat 393 formatSize = woffDirectoryEntrySize 394 zlibCompressionLevel = 6 395 396 def decodeData(self, rawData): 397 import zlib 398 if self.length == self.origLength: 399 data = rawData 400 else: 401 assert self.length < self.origLength 402 data = zlib.decompress(rawData) 403 assert len (data) == self.origLength 404 return data 405 406 def encodeData(self, data): 407 import zlib 408 self.origLength = len(data) 409 if not self.uncompressed: 410 compressedData = zlib.compress(data, self.zlibCompressionLevel) 411 if self.uncompressed or len(compressedData) >= self.origLength: 412 # Encode uncompressed 413 rawData = data 414 self.length = self.origLength 415 else: 416 rawData = compressedData 417 self.length = len(rawData) 418 return rawData 419 420class WOFFFlavorData(): 421 422 Flavor = 'woff' 423 424 def __init__(self, reader=None): 425 self.majorVersion = None 426 self.minorVersion = None 427 self.metaData = None 428 self.privData = None 429 if reader: 430 self.majorVersion = reader.majorVersion 431 self.minorVersion = reader.minorVersion 432 if reader.metaLength: 433 reader.file.seek(reader.metaOffset) 434 rawData = reader.file.read(reader.metaLength) 435 assert len(rawData) == reader.metaLength 436 import zlib 437 data = zlib.decompress(rawData) 438 assert len(data) == reader.metaOrigLength 439 self.metaData = data 440 if reader.privLength: 441 reader.file.seek(reader.privOffset) 442 data = reader.file.read(reader.privLength) 443 assert len(data) == reader.privLength 444 self.privData = data 445 446 447def calcChecksum(data): 448 """Calculate the checksum for an arbitrary block of data. 449 Optionally takes a 'start' argument, which allows you to 450 calculate a checksum in chunks by feeding it a previous 451 result. 452 453 If the data length is not a multiple of four, it assumes 454 it is to be padded with null byte. 455 456 >>> print calcChecksum(b"abcd") 457 1633837924 458 >>> print calcChecksum(b"abcdxyz") 459 3655064932 460 """ 461 remainder = len(data) % 4 462 if remainder: 463 data += b"\0" * (4 - remainder) 464 value = 0 465 blockSize = 4096 466 assert blockSize % 4 == 0 467 for i in range(0, len(data), blockSize): 468 block = data[i:i+blockSize] 469 longs = struct.unpack(">%dL" % (len(block) // 4), block) 470 value = (value + sum(longs)) & 0xffffffff 471 return value 472 473 474def maxPowerOfTwo(x): 475 """Return the highest exponent of two, so that 476 (2 ** exponent) <= x 477 """ 478 exponent = 0 479 while x: 480 x = x >> 1 481 exponent = exponent + 1 482 return max(exponent - 1, 0) 483 484 485def getSearchRange(n): 486 """Calculate searchRange, entrySelector, rangeShift for the 487 sfnt directory. 'n' is the number of tables. 488 """ 489 # This stuff needs to be stored in the file, because? 490 exponent = maxPowerOfTwo(n) 491 searchRange = (2 ** exponent) * 16 492 entrySelector = exponent 493 rangeShift = n * 16 - searchRange 494 return searchRange, entrySelector, rangeShift 495 496 497if __name__ == "__main__": 498 import doctest 499 doctest.testmod() 500