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
18from fontTools.ttLib import getSearchRange
19import struct
20
21
22class 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
98class 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
273ttcHeaderFormat = """
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
284ttcHeaderSize = sstruct.calcsize(ttcHeaderFormat)
285
286sfntDirectoryFormat = """
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
295sfntDirectorySize = sstruct.calcsize(sfntDirectoryFormat)
296
297sfntDirectoryEntryFormat = """
298		> # big endian
299		tag:            4s
300		checkSum:       L
301		offset:         L
302		length:         L
303"""
304
305sfntDirectoryEntrySize = sstruct.calcsize(sfntDirectoryEntryFormat)
306
307woffDirectoryFormat = """
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
324woffDirectorySize = sstruct.calcsize(woffDirectoryFormat)
325
326woffDirectoryEntryFormat = """
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
335woffDirectoryEntrySize = sstruct.calcsize(woffDirectoryEntryFormat)
336
337
338class 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
379class SFNTDirectoryEntry(DirectoryEntry):
380
381	format = sfntDirectoryEntryFormat
382	formatSize = sfntDirectoryEntrySize
383
384class 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
414class 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
441def 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
468if __name__ == "__main__":
469    import doctest
470    doctest.testmod()
471