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