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