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