• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 io import BytesIO
16from types import SimpleNamespace
17from fontTools.misc.py23 import Tag
18from fontTools.misc import sstruct
19from fontTools.ttLib import TTLibError
20import struct
21from collections import OrderedDict
22import logging
23
24
25log = logging.getLogger(__name__)
26
27
28class SFNTReader(object):
29
30	def __new__(cls, *args, **kwargs):
31		""" Return an instance of the SFNTReader sub-class which is compatible
32		with the input file type.
33		"""
34		if args and cls is SFNTReader:
35			infile = args[0]
36			infile.seek(0)
37			sfntVersion = Tag(infile.read(4))
38			infile.seek(0)
39			if sfntVersion == "wOF2":
40				# return new WOFF2Reader object
41				from fontTools.ttLib.woff2 import WOFF2Reader
42				return object.__new__(WOFF2Reader)
43		# return default object
44		return object.__new__(cls)
45
46	def __init__(self, file, checkChecksums=0, fontNumber=-1):
47		self.file = file
48		self.checkChecksums = checkChecksums
49
50		self.flavor = None
51		self.flavorData = None
52		self.DirectoryEntry = SFNTDirectoryEntry
53		self.file.seek(0)
54		self.sfntVersion = self.file.read(4)
55		self.file.seek(0)
56		if self.sfntVersion == b"ttcf":
57			header = readTTCHeader(self.file)
58			numFonts = header.numFonts
59			if not 0 <= fontNumber < numFonts:
60				raise TTLibError("specify a font number between 0 and %d (inclusive)" % (numFonts - 1))
61			self.numFonts = numFonts
62			self.file.seek(header.offsetTable[fontNumber])
63			data = self.file.read(sfntDirectorySize)
64			if len(data) != sfntDirectorySize:
65				raise TTLibError("Not a Font Collection (not enough data)")
66			sstruct.unpack(sfntDirectoryFormat, data, self)
67		elif self.sfntVersion == b"wOFF":
68			self.flavor = "woff"
69			self.DirectoryEntry = WOFFDirectoryEntry
70			data = self.file.read(woffDirectorySize)
71			if len(data) != woffDirectorySize:
72				raise TTLibError("Not a WOFF font (not enough data)")
73			sstruct.unpack(woffDirectoryFormat, data, self)
74		else:
75			data = self.file.read(sfntDirectorySize)
76			if len(data) != sfntDirectorySize:
77				raise TTLibError("Not a TrueType or OpenType font (not enough data)")
78			sstruct.unpack(sfntDirectoryFormat, data, self)
79		self.sfntVersion = Tag(self.sfntVersion)
80
81		if self.sfntVersion not in ("\x00\x01\x00\x00", "OTTO", "true"):
82			raise TTLibError("Not a TrueType or OpenType font (bad sfntVersion)")
83		tables = {}
84		for i in range(self.numTables):
85			entry = self.DirectoryEntry()
86			entry.fromFile(self.file)
87			tag = Tag(entry.tag)
88			tables[tag] = entry
89		self.tables = OrderedDict(sorted(tables.items(), key=lambda i: i[1].offset))
90
91		# Load flavor data if any
92		if self.flavor == "woff":
93			self.flavorData = WOFFFlavorData(self)
94
95	def has_key(self, tag):
96		return tag in self.tables
97
98	__contains__ = has_key
99
100	def keys(self):
101		return self.tables.keys()
102
103	def __getitem__(self, tag):
104		"""Fetch the raw table data."""
105		entry = self.tables[Tag(tag)]
106		data = entry.loadData (self.file)
107		if self.checkChecksums:
108			if tag == 'head':
109				# Beh: we have to special-case the 'head' table.
110				checksum = calcChecksum(data[:8] + b'\0\0\0\0' + data[12:])
111			else:
112				checksum = calcChecksum(data)
113			if self.checkChecksums > 1:
114				# Be obnoxious, and barf when it's wrong
115				assert checksum == entry.checkSum, "bad checksum for '%s' table" % tag
116			elif checksum != entry.checkSum:
117				# Be friendly, and just log a warning.
118				log.warning("bad checksum for '%s' table", tag)
119		return data
120
121	def __delitem__(self, tag):
122		del self.tables[Tag(tag)]
123
124	def close(self):
125		self.file.close()
126
127	# We define custom __getstate__ and __setstate__ to make SFNTReader pickle-able
128	# and deepcopy-able. When a TTFont is loaded as lazy=True, SFNTReader holds a
129	# reference to an external file object which is not pickleable. So in __getstate__
130	# we store the file name and current position, and in __setstate__ we reopen the
131	# same named file after unpickling.
132
133	def __getstate__(self):
134		if isinstance(self.file, BytesIO):
135			# BytesIO is already pickleable, return the state unmodified
136			return self.__dict__
137
138		# remove unpickleable file attribute, and only store its name and pos
139		state = self.__dict__.copy()
140		del state["file"]
141		state["_filename"] = self.file.name
142		state["_filepos"] = self.file.tell()
143		return state
144
145	def __setstate__(self, state):
146		if "file" not in state:
147			self.file = open(state.pop("_filename"), "rb")
148			self.file.seek(state.pop("_filepos"))
149		self.__dict__.update(state)
150
151
152# default compression level for WOFF 1.0 tables and metadata
153ZLIB_COMPRESSION_LEVEL = 6
154
155# if set to True, use zopfli instead of zlib for compressing WOFF 1.0.
156# The Python bindings are available at https://pypi.python.org/pypi/zopfli
157USE_ZOPFLI = False
158
159# mapping between zlib's compression levels and zopfli's 'numiterations'.
160# Use lower values for files over several MB in size or it will be too slow
161ZOPFLI_LEVELS = {
162	# 0: 0,  # can't do 0 iterations...
163	1: 1,
164	2: 3,
165	3: 5,
166	4: 8,
167	5: 10,
168	6: 15,
169	7: 25,
170	8: 50,
171	9: 100,
172}
173
174
175def compress(data, level=ZLIB_COMPRESSION_LEVEL):
176	""" Compress 'data' to Zlib format. If 'USE_ZOPFLI' variable is True,
177	zopfli is used instead of the zlib module.
178	The compression 'level' must be between 0 and 9. 1 gives best speed,
179	9 gives best compression (0 gives no compression at all).
180	The default value is a compromise between speed and compression (6).
181	"""
182	if not (0 <= level <= 9):
183		raise ValueError('Bad compression level: %s' % level)
184	if not USE_ZOPFLI or level == 0:
185		from zlib import compress
186		return compress(data, level)
187	else:
188		from zopfli.zlib import compress
189		return compress(data, numiterations=ZOPFLI_LEVELS[level])
190
191
192class SFNTWriter(object):
193
194	def __new__(cls, *args, **kwargs):
195		""" Return an instance of the SFNTWriter sub-class which is compatible
196		with the specified 'flavor'.
197		"""
198		flavor = None
199		if kwargs and 'flavor' in kwargs:
200			flavor = kwargs['flavor']
201		elif args and len(args) > 3:
202			flavor = args[3]
203		if cls is SFNTWriter:
204			if flavor == "woff2":
205				# return new WOFF2Writer object
206				from fontTools.ttLib.woff2 import WOFF2Writer
207				return object.__new__(WOFF2Writer)
208		# return default object
209		return object.__new__(cls)
210
211	def __init__(self, file, numTables, sfntVersion="\000\001\000\000",
212			flavor=None, flavorData=None):
213		self.file = file
214		self.numTables = numTables
215		self.sfntVersion = Tag(sfntVersion)
216		self.flavor = flavor
217		self.flavorData = flavorData
218
219		if self.flavor == "woff":
220			self.directoryFormat = woffDirectoryFormat
221			self.directorySize = woffDirectorySize
222			self.DirectoryEntry = WOFFDirectoryEntry
223
224			self.signature = "wOFF"
225
226			# to calculate WOFF checksum adjustment, we also need the original SFNT offsets
227			self.origNextTableOffset = sfntDirectorySize + numTables * sfntDirectoryEntrySize
228		else:
229			assert not self.flavor, "Unknown flavor '%s'" % self.flavor
230			self.directoryFormat = sfntDirectoryFormat
231			self.directorySize = sfntDirectorySize
232			self.DirectoryEntry = SFNTDirectoryEntry
233
234			from fontTools.ttLib import getSearchRange
235			self.searchRange, self.entrySelector, self.rangeShift = getSearchRange(numTables, 16)
236
237		self.directoryOffset = self.file.tell()
238		self.nextTableOffset = self.directoryOffset + self.directorySize + numTables * self.DirectoryEntry.formatSize
239		# clear out directory area
240		self.file.seek(self.nextTableOffset)
241		# make sure we're actually where we want to be. (old cStringIO bug)
242		self.file.write(b'\0' * (self.nextTableOffset - self.file.tell()))
243		self.tables = OrderedDict()
244
245	def setEntry(self, tag, entry):
246		if tag in self.tables:
247			raise TTLibError("cannot rewrite '%s' table" % tag)
248
249		self.tables[tag] = entry
250
251	def __setitem__(self, tag, data):
252		"""Write raw table data to disk."""
253		if tag in self.tables:
254			raise TTLibError("cannot rewrite '%s' table" % tag)
255
256		entry = self.DirectoryEntry()
257		entry.tag = tag
258		entry.offset = self.nextTableOffset
259		if tag == 'head':
260			entry.checkSum = calcChecksum(data[:8] + b'\0\0\0\0' + data[12:])
261			self.headTable = data
262			entry.uncompressed = True
263		else:
264			entry.checkSum = calcChecksum(data)
265		entry.saveData(self.file, data)
266
267		if self.flavor == "woff":
268			entry.origOffset = self.origNextTableOffset
269			self.origNextTableOffset += (entry.origLength + 3) & ~3
270
271		self.nextTableOffset = self.nextTableOffset + ((entry.length + 3) & ~3)
272		# Add NUL bytes to pad the table data to a 4-byte boundary.
273		# Don't depend on f.seek() as we need to add the padding even if no
274		# subsequent write follows (seek is lazy), ie. after the final table
275		# in the font.
276		self.file.write(b'\0' * (self.nextTableOffset - self.file.tell()))
277		assert self.nextTableOffset == self.file.tell()
278
279		self.setEntry(tag, entry)
280
281	def __getitem__(self, tag):
282		return self.tables[tag]
283
284	def close(self):
285		"""All tables must have been written to disk. Now write the
286		directory.
287		"""
288		tables = sorted(self.tables.items())
289		if len(tables) != self.numTables:
290			raise TTLibError("wrong number of tables; expected %d, found %d" % (self.numTables, len(tables)))
291
292		if self.flavor == "woff":
293			self.signature = b"wOFF"
294			self.reserved = 0
295
296			self.totalSfntSize = 12
297			self.totalSfntSize += 16 * len(tables)
298			for tag, entry in tables:
299				self.totalSfntSize += (entry.origLength + 3) & ~3
300
301			data = self.flavorData if self.flavorData else WOFFFlavorData()
302			if data.majorVersion is not None and data.minorVersion is not None:
303				self.majorVersion = data.majorVersion
304				self.minorVersion = data.minorVersion
305			else:
306				if hasattr(self, 'headTable'):
307					self.majorVersion, self.minorVersion = struct.unpack(">HH", self.headTable[4:8])
308				else:
309					self.majorVersion = self.minorVersion = 0
310			if data.metaData:
311				self.metaOrigLength = len(data.metaData)
312				self.file.seek(0,2)
313				self.metaOffset = self.file.tell()
314				compressedMetaData = compress(data.metaData)
315				self.metaLength = len(compressedMetaData)
316				self.file.write(compressedMetaData)
317			else:
318				self.metaOffset = self.metaLength = self.metaOrigLength = 0
319			if data.privData:
320				self.file.seek(0,2)
321				off = self.file.tell()
322				paddedOff = (off + 3) & ~3
323				self.file.write('\0' * (paddedOff - off))
324				self.privOffset = self.file.tell()
325				self.privLength = len(data.privData)
326				self.file.write(data.privData)
327			else:
328				self.privOffset = self.privLength = 0
329
330			self.file.seek(0,2)
331			self.length = self.file.tell()
332
333		else:
334			assert not self.flavor, "Unknown flavor '%s'" % self.flavor
335			pass
336
337		directory = sstruct.pack(self.directoryFormat, self)
338
339		self.file.seek(self.directoryOffset + self.directorySize)
340		seenHead = 0
341		for tag, entry in tables:
342			if tag == "head":
343				seenHead = 1
344			directory = directory + entry.toString()
345		if seenHead:
346			self.writeMasterChecksum(directory)
347		self.file.seek(self.directoryOffset)
348		self.file.write(directory)
349
350	def _calcMasterChecksum(self, directory):
351		# calculate checkSumAdjustment
352		tags = list(self.tables.keys())
353		checksums = []
354		for i in range(len(tags)):
355			checksums.append(self.tables[tags[i]].checkSum)
356
357		if self.DirectoryEntry != SFNTDirectoryEntry:
358			# Create a SFNT directory for checksum calculation purposes
359			from fontTools.ttLib import getSearchRange
360			self.searchRange, self.entrySelector, self.rangeShift = getSearchRange(self.numTables, 16)
361			directory = sstruct.pack(sfntDirectoryFormat, self)
362			tables = sorted(self.tables.items())
363			for tag, entry in tables:
364				sfntEntry = SFNTDirectoryEntry()
365				sfntEntry.tag = entry.tag
366				sfntEntry.checkSum = entry.checkSum
367				sfntEntry.offset = entry.origOffset
368				sfntEntry.length = entry.origLength
369				directory = directory + sfntEntry.toString()
370
371		directory_end = sfntDirectorySize + len(self.tables) * sfntDirectoryEntrySize
372		assert directory_end == len(directory)
373
374		checksums.append(calcChecksum(directory))
375		checksum = sum(checksums) & 0xffffffff
376		# BiboAfba!
377		checksumadjustment = (0xB1B0AFBA - checksum) & 0xffffffff
378		return checksumadjustment
379
380	def writeMasterChecksum(self, directory):
381		checksumadjustment = self._calcMasterChecksum(directory)
382		# write the checksum to the file
383		self.file.seek(self.tables['head'].offset + 8)
384		self.file.write(struct.pack(">L", checksumadjustment))
385
386	def reordersTables(self):
387		return False
388
389
390# -- sfnt directory helpers and cruft
391
392ttcHeaderFormat = """
393		> # big endian
394		TTCTag:                  4s # "ttcf"
395		Version:                 L  # 0x00010000 or 0x00020000
396		numFonts:                L  # number of fonts
397		# OffsetTable[numFonts]: L  # array with offsets from beginning of file
398		# ulDsigTag:             L  # version 2.0 only
399		# ulDsigLength:          L  # version 2.0 only
400		# ulDsigOffset:          L  # version 2.0 only
401"""
402
403ttcHeaderSize = sstruct.calcsize(ttcHeaderFormat)
404
405sfntDirectoryFormat = """
406		> # big endian
407		sfntVersion:    4s
408		numTables:      H    # number of tables
409		searchRange:    H    # (max2 <= numTables)*16
410		entrySelector:  H    # log2(max2 <= numTables)
411		rangeShift:     H    # numTables*16-searchRange
412"""
413
414sfntDirectorySize = sstruct.calcsize(sfntDirectoryFormat)
415
416sfntDirectoryEntryFormat = """
417		> # big endian
418		tag:            4s
419		checkSum:       L
420		offset:         L
421		length:         L
422"""
423
424sfntDirectoryEntrySize = sstruct.calcsize(sfntDirectoryEntryFormat)
425
426woffDirectoryFormat = """
427		> # big endian
428		signature:      4s   # "wOFF"
429		sfntVersion:    4s
430		length:         L    # total woff file size
431		numTables:      H    # number of tables
432		reserved:       H    # set to 0
433		totalSfntSize:  L    # uncompressed size
434		majorVersion:   H    # major version of WOFF file
435		minorVersion:   H    # minor version of WOFF file
436		metaOffset:     L    # offset to metadata block
437		metaLength:     L    # length of compressed metadata
438		metaOrigLength: L    # length of uncompressed metadata
439		privOffset:     L    # offset to private data block
440		privLength:     L    # length of private data block
441"""
442
443woffDirectorySize = sstruct.calcsize(woffDirectoryFormat)
444
445woffDirectoryEntryFormat = """
446		> # big endian
447		tag:            4s
448		offset:         L
449		length:         L    # compressed length
450		origLength:     L    # original length
451		checkSum:       L    # original checksum
452"""
453
454woffDirectoryEntrySize = sstruct.calcsize(woffDirectoryEntryFormat)
455
456
457class DirectoryEntry(object):
458
459	def __init__(self):
460		self.uncompressed = False # if True, always embed entry raw
461
462	def fromFile(self, file):
463		sstruct.unpack(self.format, file.read(self.formatSize), self)
464
465	def fromString(self, str):
466		sstruct.unpack(self.format, str, self)
467
468	def toString(self):
469		return sstruct.pack(self.format, self)
470
471	def __repr__(self):
472		if hasattr(self, "tag"):
473			return "<%s '%s' at %x>" % (self.__class__.__name__, self.tag, id(self))
474		else:
475			return "<%s at %x>" % (self.__class__.__name__, id(self))
476
477	def loadData(self, file):
478		file.seek(self.offset)
479		data = file.read(self.length)
480		assert len(data) == self.length
481		if hasattr(self.__class__, 'decodeData'):
482			data = self.decodeData(data)
483		return data
484
485	def saveData(self, file, data):
486		if hasattr(self.__class__, 'encodeData'):
487			data = self.encodeData(data)
488		self.length = len(data)
489		file.seek(self.offset)
490		file.write(data)
491
492	def decodeData(self, rawData):
493		return rawData
494
495	def encodeData(self, data):
496		return data
497
498class SFNTDirectoryEntry(DirectoryEntry):
499
500	format = sfntDirectoryEntryFormat
501	formatSize = sfntDirectoryEntrySize
502
503class WOFFDirectoryEntry(DirectoryEntry):
504
505	format = woffDirectoryEntryFormat
506	formatSize = woffDirectoryEntrySize
507
508	def __init__(self):
509		super(WOFFDirectoryEntry, self).__init__()
510		# With fonttools<=3.1.2, the only way to set a different zlib
511		# compression level for WOFF directory entries was to set the class
512		# attribute 'zlibCompressionLevel'. This is now replaced by a globally
513		# defined `ZLIB_COMPRESSION_LEVEL`, which is also applied when
514		# compressing the metadata. For backward compatibility, we still
515		# use the class attribute if it was already set.
516		if not hasattr(WOFFDirectoryEntry, 'zlibCompressionLevel'):
517			self.zlibCompressionLevel = ZLIB_COMPRESSION_LEVEL
518
519	def decodeData(self, rawData):
520		import zlib
521		if self.length == self.origLength:
522			data = rawData
523		else:
524			assert self.length < self.origLength
525			data = zlib.decompress(rawData)
526			assert len(data) == self.origLength
527		return data
528
529	def encodeData(self, data):
530		self.origLength = len(data)
531		if not self.uncompressed:
532			compressedData = compress(data, self.zlibCompressionLevel)
533		if self.uncompressed or len(compressedData) >= self.origLength:
534			# Encode uncompressed
535			rawData = data
536			self.length = self.origLength
537		else:
538			rawData = compressedData
539			self.length = len(rawData)
540		return rawData
541
542class WOFFFlavorData():
543
544	Flavor = 'woff'
545
546	def __init__(self, reader=None):
547		self.majorVersion = None
548		self.minorVersion = None
549		self.metaData = None
550		self.privData = None
551		if reader:
552			self.majorVersion = reader.majorVersion
553			self.minorVersion = reader.minorVersion
554			if reader.metaLength:
555				reader.file.seek(reader.metaOffset)
556				rawData = reader.file.read(reader.metaLength)
557				assert len(rawData) == reader.metaLength
558				data = self._decompress(rawData)
559				assert len(data) == reader.metaOrigLength
560				self.metaData = data
561			if reader.privLength:
562				reader.file.seek(reader.privOffset)
563				data = reader.file.read(reader.privLength)
564				assert len(data) == reader.privLength
565				self.privData = data
566
567	def _decompress(self, rawData):
568		import zlib
569		return zlib.decompress(rawData)
570
571
572def calcChecksum(data):
573	"""Calculate the checksum for an arbitrary block of data.
574	Optionally takes a 'start' argument, which allows you to
575	calculate a checksum in chunks by feeding it a previous
576	result.
577
578	If the data length is not a multiple of four, it assumes
579	it is to be padded with null byte.
580
581		>>> print(calcChecksum(b"abcd"))
582		1633837924
583		>>> print(calcChecksum(b"abcdxyz"))
584		3655064932
585	"""
586	remainder = len(data) % 4
587	if remainder:
588		data += b"\0" * (4 - remainder)
589	value = 0
590	blockSize = 4096
591	assert blockSize % 4 == 0
592	for i in range(0, len(data), blockSize):
593		block = data[i:i+blockSize]
594		longs = struct.unpack(">%dL" % (len(block) // 4), block)
595		value = (value + sum(longs)) & 0xffffffff
596	return value
597
598def readTTCHeader(file):
599	file.seek(0)
600	data = file.read(ttcHeaderSize)
601	if len(data) != ttcHeaderSize:
602		raise TTLibError("Not a Font Collection (not enough data)")
603	self = SimpleNamespace()
604	sstruct.unpack(ttcHeaderFormat, data, self)
605	if self.TTCTag != "ttcf":
606		raise TTLibError("Not a Font Collection")
607	assert self.Version == 0x00010000 or self.Version == 0x00020000, "unrecognized TTC version 0x%08x" % self.Version
608	self.offsetTable = struct.unpack(">%dL" % self.numFonts, file.read(self.numFonts * 4))
609	if self.Version == 0x00020000:
610		pass # ignoring version 2.0 signatures
611	return self
612
613def writeTTCHeader(file, numFonts):
614	self = SimpleNamespace()
615	self.TTCTag = 'ttcf'
616	self.Version = 0x00010000
617	self.numFonts = numFonts
618	file.seek(0)
619	file.write(sstruct.pack(ttcHeaderFormat, self))
620	offset = file.tell()
621	file.write(struct.pack(">%dL" % self.numFonts, *([0] * self.numFonts)))
622	return offset
623
624if __name__ == "__main__":
625	import sys
626	import doctest
627	sys.exit(doctest.testmod().failed)
628