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