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