• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1from __future__ import print_function, division, absolute_import, unicode_literals
2from fontTools.misc.py23 import *
3from fontTools import ttLib
4from fontTools.ttLib import woff2
5from fontTools.ttLib.woff2 import (
6	WOFF2Reader, woff2DirectorySize, woff2DirectoryFormat,
7	woff2FlagsSize, woff2UnknownTagSize, woff2Base128MaxSize, WOFF2DirectoryEntry,
8	getKnownTagIndex, packBase128, base128Size, woff2UnknownTagIndex,
9	WOFF2FlavorData, woff2TransformedTableTags, WOFF2GlyfTable, WOFF2LocaTable,
10	WOFF2HmtxTable, WOFF2Writer, unpackBase128, unpack255UShort, pack255UShort)
11import unittest
12from fontTools.misc import sstruct
13from fontTools import fontBuilder
14from fontTools.pens.ttGlyphPen import TTGlyphPen
15import struct
16import os
17import random
18import copy
19from collections import OrderedDict
20from functools import partial
21import pytest
22
23haveBrotli = False
24try:
25	import brotli
26	haveBrotli = True
27except ImportError:
28	pass
29
30
31# Python 3 renamed 'assertRaisesRegexp' to 'assertRaisesRegex', and fires
32# deprecation warnings if a program uses the old name.
33if not hasattr(unittest.TestCase, 'assertRaisesRegex'):
34	unittest.TestCase.assertRaisesRegex = unittest.TestCase.assertRaisesRegexp
35
36
37current_dir = os.path.abspath(os.path.dirname(os.path.realpath(__file__)))
38data_dir = os.path.join(current_dir, 'data')
39TTX = os.path.join(data_dir, 'TestTTF-Regular.ttx')
40OTX = os.path.join(data_dir, 'TestOTF-Regular.otx')
41METADATA = os.path.join(data_dir, 'test_woff2_metadata.xml')
42
43TT_WOFF2 = BytesIO()
44CFF_WOFF2 = BytesIO()
45
46
47def setUpModule():
48	if not haveBrotli:
49		raise unittest.SkipTest("No module named brotli")
50	assert os.path.exists(TTX)
51	assert os.path.exists(OTX)
52	# import TT-flavoured test font and save it as WOFF2
53	ttf = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False)
54	ttf.importXML(TTX)
55	ttf.flavor = "woff2"
56	ttf.save(TT_WOFF2, reorderTables=None)
57	# import CFF-flavoured test font and save it as WOFF2
58	otf = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False)
59	otf.importXML(OTX)
60	otf.flavor = "woff2"
61	otf.save(CFF_WOFF2, reorderTables=None)
62
63
64class WOFF2ReaderTest(unittest.TestCase):
65
66	@classmethod
67	def setUpClass(cls):
68		cls.file = BytesIO(CFF_WOFF2.getvalue())
69		cls.font = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False)
70		cls.font.importXML(OTX)
71
72	def setUp(self):
73		self.file.seek(0)
74
75	def test_bad_signature(self):
76		with self.assertRaisesRegex(ttLib.TTLibError, 'bad signature'):
77			WOFF2Reader(BytesIO(b"wOFF"))
78
79	def test_not_enough_data_header(self):
80		incomplete_header = self.file.read(woff2DirectorySize - 1)
81		with self.assertRaisesRegex(ttLib.TTLibError, 'not enough data'):
82			WOFF2Reader(BytesIO(incomplete_header))
83
84	def test_incorrect_compressed_size(self):
85		data = self.file.read(woff2DirectorySize)
86		header = sstruct.unpack(woff2DirectoryFormat, data)
87		header['totalCompressedSize'] = 0
88		data = sstruct.pack(woff2DirectoryFormat, header)
89		with self.assertRaises((brotli.error, ttLib.TTLibError)):
90			WOFF2Reader(BytesIO(data + self.file.read()))
91
92	def test_incorrect_uncompressed_size(self):
93		decompress_backup = brotli.decompress
94		brotli.decompress = lambda data: b""  # return empty byte string
95		with self.assertRaisesRegex(ttLib.TTLibError, 'unexpected size for decompressed'):
96			WOFF2Reader(self.file)
97		brotli.decompress = decompress_backup
98
99	def test_incorrect_file_size(self):
100		data = self.file.read(woff2DirectorySize)
101		header = sstruct.unpack(woff2DirectoryFormat, data)
102		header['length'] -= 1
103		data = sstruct.pack(woff2DirectoryFormat, header)
104		with self.assertRaisesRegex(
105				ttLib.TTLibError, "doesn't match the actual file size"):
106			WOFF2Reader(BytesIO(data + self.file.read()))
107
108	def test_num_tables(self):
109		tags = [t for t in self.font.keys() if t not in ('GlyphOrder', 'DSIG')]
110		data = self.file.read(woff2DirectorySize)
111		header = sstruct.unpack(woff2DirectoryFormat, data)
112		self.assertEqual(header['numTables'], len(tags))
113
114	def test_table_tags(self):
115		tags = set([t for t in self.font.keys() if t not in ('GlyphOrder', 'DSIG')])
116		reader = WOFF2Reader(self.file)
117		self.assertEqual(set(reader.keys()), tags)
118
119	def test_get_normal_tables(self):
120		woff2Reader = WOFF2Reader(self.file)
121		specialTags = woff2TransformedTableTags + ('head', 'GlyphOrder', 'DSIG')
122		for tag in [t for t in self.font.keys() if t not in specialTags]:
123			origData = self.font.getTableData(tag)
124			decompressedData = woff2Reader[tag]
125			self.assertEqual(origData, decompressedData)
126
127	def test_reconstruct_unknown(self):
128		reader = WOFF2Reader(self.file)
129		with self.assertRaisesRegex(ttLib.TTLibError, 'transform for table .* unknown'):
130			reader.reconstructTable('head')
131
132
133class WOFF2ReaderTTFTest(WOFF2ReaderTest):
134	""" Tests specific to TT-flavored fonts. """
135
136	@classmethod
137	def setUpClass(cls):
138		cls.file = BytesIO(TT_WOFF2.getvalue())
139		cls.font = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False)
140		cls.font.importXML(TTX)
141
142	def setUp(self):
143		self.file.seek(0)
144
145	def test_reconstruct_glyf(self):
146		woff2Reader = WOFF2Reader(self.file)
147		reconstructedData = woff2Reader['glyf']
148		self.assertEqual(self.font.getTableData('glyf'), reconstructedData)
149
150	def test_reconstruct_loca(self):
151		woff2Reader = WOFF2Reader(self.file)
152		reconstructedData = woff2Reader['loca']
153		self.font.getTableData("glyf")  # 'glyf' needs to be compiled before 'loca'
154		self.assertEqual(self.font.getTableData('loca'), reconstructedData)
155		self.assertTrue(hasattr(woff2Reader.tables['glyf'], 'data'))
156
157	def test_reconstruct_loca_not_match_orig_size(self):
158		reader = WOFF2Reader(self.file)
159		reader.tables['loca'].origLength -= 1
160		with self.assertRaisesRegex(
161				ttLib.TTLibError, "'loca' table doesn't match original size"):
162			reader.reconstructTable('loca')
163
164
165def normalise_table(font, tag, padding=4):
166	""" Return normalised table data. Keep 'font' instance unmodified. """
167	assert tag in ('glyf', 'loca', 'head')
168	assert tag in font
169	if tag == 'head':
170		origHeadFlags = font['head'].flags
171		font['head'].flags |= (1 << 11)
172		tableData = font['head'].compile(font)
173	if font.sfntVersion in ("\x00\x01\x00\x00", "true"):
174		assert {'glyf', 'loca', 'head'}.issubset(font.keys())
175		origIndexFormat = font['head'].indexToLocFormat
176		if hasattr(font['loca'], 'locations'):
177			origLocations = font['loca'].locations[:]
178		else:
179			origLocations = []
180		glyfTable = ttLib.newTable('glyf')
181		glyfTable.decompile(font.getTableData('glyf'), font)
182		glyfTable.padding = padding
183		if tag == 'glyf':
184			tableData = glyfTable.compile(font)
185		elif tag == 'loca':
186			glyfTable.compile(font)
187			tableData = font['loca'].compile(font)
188		if tag == 'head':
189			glyfTable.compile(font)
190			font['loca'].compile(font)
191			tableData = font['head'].compile(font)
192		font['head'].indexToLocFormat = origIndexFormat
193		font['loca'].set(origLocations)
194	if tag == 'head':
195		font['head'].flags = origHeadFlags
196	return tableData
197
198
199def normalise_font(font, padding=4):
200	""" Return normalised font data. Keep 'font' instance unmodified. """
201	# drop DSIG but keep a copy
202	DSIG_copy = copy.deepcopy(font['DSIG'])
203	del font['DSIG']
204	# ovverride TTFont attributes
205	origFlavor = font.flavor
206	origRecalcBBoxes = font.recalcBBoxes
207	origRecalcTimestamp = font.recalcTimestamp
208	origLazy = font.lazy
209	font.flavor = None
210	font.recalcBBoxes = False
211	font.recalcTimestamp = False
212	font.lazy = True
213	# save font to temporary stream
214	infile = BytesIO()
215	font.save(infile)
216	infile.seek(0)
217	# reorder tables alphabetically
218	outfile = BytesIO()
219	reader = ttLib.sfnt.SFNTReader(infile)
220	writer = ttLib.sfnt.SFNTWriter(
221		outfile, len(reader.tables), reader.sfntVersion, reader.flavor, reader.flavorData)
222	for tag in sorted(reader.keys()):
223		if tag in woff2TransformedTableTags + ('head',):
224			writer[tag] = normalise_table(font, tag, padding)
225		else:
226			writer[tag] = reader[tag]
227	writer.close()
228	# restore font attributes
229	font['DSIG'] = DSIG_copy
230	font.flavor = origFlavor
231	font.recalcBBoxes = origRecalcBBoxes
232	font.recalcTimestamp = origRecalcTimestamp
233	font.lazy = origLazy
234	return outfile.getvalue()
235
236
237class WOFF2DirectoryEntryTest(unittest.TestCase):
238
239	def setUp(self):
240		self.entry = WOFF2DirectoryEntry()
241
242	def test_not_enough_data_table_flags(self):
243		with self.assertRaisesRegex(ttLib.TTLibError, "can't read table 'flags'"):
244			self.entry.fromString(b"")
245
246	def test_not_enough_data_table_tag(self):
247		incompleteData = bytearray([0x3F, 0, 0, 0])
248		with self.assertRaisesRegex(ttLib.TTLibError, "can't read table 'tag'"):
249			self.entry.fromString(bytes(incompleteData))
250
251	def test_loca_zero_transformLength(self):
252		data = bytechr(getKnownTagIndex('loca'))  # flags
253		data += packBase128(random.randint(1, 100))  # origLength
254		data += packBase128(1)  # non-zero transformLength
255		with self.assertRaisesRegex(
256				ttLib.TTLibError, "transformLength of the 'loca' table must be 0"):
257			self.entry.fromString(data)
258
259	def test_fromFile(self):
260		unknownTag = Tag('ZZZZ')
261		data = bytechr(getKnownTagIndex(unknownTag))
262		data += unknownTag.tobytes()
263		data += packBase128(random.randint(1, 100))
264		expectedPos = len(data)
265		f = BytesIO(data + b'\0'*100)
266		self.entry.fromFile(f)
267		self.assertEqual(f.tell(), expectedPos)
268
269	def test_transformed_toString(self):
270		self.entry.tag = Tag('glyf')
271		self.entry.flags = getKnownTagIndex(self.entry.tag)
272		self.entry.origLength = random.randint(101, 200)
273		self.entry.length = random.randint(1, 100)
274		expectedSize = (woff2FlagsSize + base128Size(self.entry.origLength) +
275			base128Size(self.entry.length))
276		data = self.entry.toString()
277		self.assertEqual(len(data), expectedSize)
278
279	def test_known_toString(self):
280		self.entry.tag = Tag('head')
281		self.entry.flags = getKnownTagIndex(self.entry.tag)
282		self.entry.origLength = 54
283		expectedSize = (woff2FlagsSize + base128Size(self.entry.origLength))
284		data = self.entry.toString()
285		self.assertEqual(len(data), expectedSize)
286
287	def test_unknown_toString(self):
288		self.entry.tag = Tag('ZZZZ')
289		self.entry.flags = woff2UnknownTagIndex
290		self.entry.origLength = random.randint(1, 100)
291		expectedSize = (woff2FlagsSize + woff2UnknownTagSize +
292			base128Size(self.entry.origLength))
293		data = self.entry.toString()
294		self.assertEqual(len(data), expectedSize)
295
296	def test_glyf_loca_transform_flags(self):
297		for tag in ("glyf", "loca"):
298			entry = WOFF2DirectoryEntry()
299			entry.tag = Tag(tag)
300			entry.flags = getKnownTagIndex(entry.tag)
301
302			self.assertEqual(entry.transformVersion, 0)
303			self.assertTrue(entry.transformed)
304
305			entry.transformed = False
306
307			self.assertEqual(entry.transformVersion, 3)
308			self.assertEqual(entry.flags & 0b11000000, (3 << 6))
309			self.assertFalse(entry.transformed)
310
311	def test_other_transform_flags(self):
312		entry = WOFF2DirectoryEntry()
313		entry.tag = Tag('ZZZZ')
314		entry.flags = woff2UnknownTagIndex
315
316		self.assertEqual(entry.transformVersion, 0)
317		self.assertFalse(entry.transformed)
318
319		entry.transformed = True
320
321		self.assertEqual(entry.transformVersion, 1)
322		self.assertEqual(entry.flags & 0b11000000, (1 << 6))
323		self.assertTrue(entry.transformed)
324
325
326class DummyReader(WOFF2Reader):
327
328	def __init__(self, file, checkChecksums=1, fontNumber=-1):
329		self.file = file
330		for attr in ('majorVersion', 'minorVersion', 'metaOffset', 'metaLength',
331				'metaOrigLength', 'privLength', 'privOffset'):
332			setattr(self, attr, 0)
333		self.tables = {}
334
335
336class WOFF2FlavorDataTest(unittest.TestCase):
337
338	@classmethod
339	def setUpClass(cls):
340		assert os.path.exists(METADATA)
341		with open(METADATA, 'rb') as f:
342			cls.xml_metadata = f.read()
343		cls.compressed_metadata = brotli.compress(cls.xml_metadata, mode=brotli.MODE_TEXT)
344		# make random byte strings; font data must be 4-byte aligned
345		cls.fontdata = bytes(bytearray(random.sample(range(0, 256), 80)))
346		cls.privData = bytes(bytearray(random.sample(range(0, 256), 20)))
347
348	def setUp(self):
349		self.file = BytesIO(self.fontdata)
350		self.file.seek(0, 2)
351
352	def test_get_metaData_no_privData(self):
353		self.file.write(self.compressed_metadata)
354		reader = DummyReader(self.file)
355		reader.metaOffset = len(self.fontdata)
356		reader.metaLength = len(self.compressed_metadata)
357		reader.metaOrigLength = len(self.xml_metadata)
358		flavorData = WOFF2FlavorData(reader)
359		self.assertEqual(self.xml_metadata, flavorData.metaData)
360
361	def test_get_privData_no_metaData(self):
362		self.file.write(self.privData)
363		reader = DummyReader(self.file)
364		reader.privOffset = len(self.fontdata)
365		reader.privLength = len(self.privData)
366		flavorData = WOFF2FlavorData(reader)
367		self.assertEqual(self.privData, flavorData.privData)
368
369	def test_get_metaData_and_privData(self):
370		self.file.write(self.compressed_metadata + self.privData)
371		reader = DummyReader(self.file)
372		reader.metaOffset = len(self.fontdata)
373		reader.metaLength = len(self.compressed_metadata)
374		reader.metaOrigLength = len(self.xml_metadata)
375		reader.privOffset = reader.metaOffset + reader.metaLength
376		reader.privLength = len(self.privData)
377		flavorData = WOFF2FlavorData(reader)
378		self.assertEqual(self.xml_metadata, flavorData.metaData)
379		self.assertEqual(self.privData, flavorData.privData)
380
381	def test_get_major_minorVersion(self):
382		reader = DummyReader(self.file)
383		reader.majorVersion = reader.minorVersion = 1
384		flavorData = WOFF2FlavorData(reader)
385		self.assertEqual(flavorData.majorVersion, 1)
386		self.assertEqual(flavorData.minorVersion, 1)
387
388	def test_mutually_exclusive_args(self):
389		msg = "arguments are mutually exclusive"
390		reader = DummyReader(self.file)
391		with self.assertRaisesRegex(TypeError, msg):
392			WOFF2FlavorData(reader, transformedTables={"hmtx"})
393		with self.assertRaisesRegex(TypeError, msg):
394			WOFF2FlavorData(reader, data=WOFF2FlavorData())
395
396	def test_transformedTables_default(self):
397		flavorData = WOFF2FlavorData()
398		self.assertEqual(flavorData.transformedTables, set(woff2TransformedTableTags))
399
400	def test_transformedTables_invalid(self):
401		msg = r"'glyf' and 'loca' must be transformed \(or not\) together"
402
403		with self.assertRaisesRegex(ValueError, msg):
404			WOFF2FlavorData(transformedTables={"glyf"})
405
406		with self.assertRaisesRegex(ValueError, msg):
407			WOFF2FlavorData(transformedTables={"loca"})
408
409
410class WOFF2WriterTest(unittest.TestCase):
411
412	@classmethod
413	def setUpClass(cls):
414		cls.font = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False, flavor="woff2")
415		cls.font.importXML(OTX)
416		cls.tags = sorted(t for t in cls.font.keys() if t != 'GlyphOrder')
417		cls.numTables = len(cls.tags)
418		cls.file = BytesIO(CFF_WOFF2.getvalue())
419		cls.file.seek(0, 2)
420		cls.length = (cls.file.tell() + 3) & ~3
421		cls.setUpFlavorData()
422
423	@classmethod
424	def setUpFlavorData(cls):
425		assert os.path.exists(METADATA)
426		with open(METADATA, 'rb') as f:
427			cls.xml_metadata = f.read()
428		cls.compressed_metadata = brotli.compress(cls.xml_metadata, mode=brotli.MODE_TEXT)
429		cls.privData = bytes(bytearray(random.sample(range(0, 256), 20)))
430
431	def setUp(self):
432		self.file.seek(0)
433		self.writer = WOFF2Writer(BytesIO(), self.numTables, self.font.sfntVersion)
434
435	def test_DSIG_dropped(self):
436		self.writer['DSIG'] = b"\0"
437		self.assertEqual(len(self.writer.tables), 0)
438		self.assertEqual(self.writer.numTables, self.numTables-1)
439
440	def test_no_rewrite_table(self):
441		self.writer['ZZZZ'] = b"\0"
442		with self.assertRaisesRegex(ttLib.TTLibError, "cannot rewrite"):
443			self.writer['ZZZZ'] = b"\0"
444
445	def test_num_tables(self):
446		self.writer['ABCD'] = b"\0"
447		with self.assertRaisesRegex(ttLib.TTLibError, "wrong number of tables"):
448			self.writer.close()
449
450	def test_required_tables(self):
451		font = ttLib.TTFont(flavor="woff2")
452		with self.assertRaisesRegex(ttLib.TTLibError, "missing required table"):
453			font.save(BytesIO())
454
455	def test_head_transform_flag(self):
456		headData = self.font.getTableData('head')
457		origFlags = byteord(headData[16])
458		woff2font = ttLib.TTFont(self.file)
459		newHeadData = woff2font.getTableData('head')
460		modifiedFlags = byteord(newHeadData[16])
461		self.assertNotEqual(origFlags, modifiedFlags)
462		restoredFlags = modifiedFlags & ~0x08  # turn off bit 11
463		self.assertEqual(origFlags, restoredFlags)
464
465	def test_tables_sorted_alphabetically(self):
466		expected = sorted([t for t in self.tags if t != 'DSIG'])
467		woff2font = ttLib.TTFont(self.file)
468		self.assertEqual(expected, list(woff2font.reader.keys()))
469
470	def test_checksums(self):
471		normFile = BytesIO(normalise_font(self.font, padding=4))
472		normFile.seek(0)
473		normFont = ttLib.TTFont(normFile, checkChecksums=2)
474		w2font = ttLib.TTFont(self.file)
475		# force reconstructing glyf table using 4-byte padding
476		w2font.reader.padding = 4
477		for tag in [t for t in self.tags if t != 'DSIG']:
478			w2data = w2font.reader[tag]
479			normData = normFont.reader[tag]
480			if tag == "head":
481				w2data = w2data[:8] + b'\0\0\0\0' + w2data[12:]
482				normData = normData[:8] + b'\0\0\0\0' + normData[12:]
483			w2CheckSum = ttLib.sfnt.calcChecksum(w2data)
484			normCheckSum = ttLib.sfnt.calcChecksum(normData)
485			self.assertEqual(w2CheckSum, normCheckSum)
486		normCheckSumAdjustment = normFont['head'].checkSumAdjustment
487		self.assertEqual(normCheckSumAdjustment, w2font['head'].checkSumAdjustment)
488
489	def test_calcSFNTChecksumsLengthsAndOffsets(self):
490		normFont = ttLib.TTFont(BytesIO(normalise_font(self.font, padding=4)))
491		for tag in self.tags:
492			self.writer[tag] = self.font.getTableData(tag)
493		self.writer._normaliseGlyfAndLoca(padding=4)
494		self.writer._setHeadTransformFlag()
495		self.writer.tables = OrderedDict(sorted(self.writer.tables.items()))
496		self.writer._calcSFNTChecksumsLengthsAndOffsets()
497		for tag, entry in normFont.reader.tables.items():
498			self.assertEqual(entry.offset, self.writer.tables[tag].origOffset)
499			self.assertEqual(entry.length, self.writer.tables[tag].origLength)
500			self.assertEqual(entry.checkSum, self.writer.tables[tag].checkSum)
501
502	def test_bad_sfntVersion(self):
503		for i in range(self.numTables):
504			self.writer[bytechr(65 + i)*4] = b"\0"
505		self.writer.sfntVersion = 'ZZZZ'
506		with self.assertRaisesRegex(ttLib.TTLibError, "bad sfntVersion"):
507			self.writer.close()
508
509	def test_calcTotalSize_no_flavorData(self):
510		expected = self.length
511		self.writer.file = BytesIO()
512		for tag in self.tags:
513			self.writer[tag] = self.font.getTableData(tag)
514		self.writer.close()
515		self.assertEqual(expected, self.writer.length)
516		self.assertEqual(expected, self.writer.file.tell())
517
518	def test_calcTotalSize_with_metaData(self):
519		expected = self.length + len(self.compressed_metadata)
520		flavorData = self.writer.flavorData = WOFF2FlavorData()
521		flavorData.metaData = self.xml_metadata
522		self.writer.file = BytesIO()
523		for tag in self.tags:
524			self.writer[tag] = self.font.getTableData(tag)
525		self.writer.close()
526		self.assertEqual(expected, self.writer.length)
527		self.assertEqual(expected, self.writer.file.tell())
528
529	def test_calcTotalSize_with_privData(self):
530		expected = self.length + len(self.privData)
531		flavorData = self.writer.flavorData = WOFF2FlavorData()
532		flavorData.privData = self.privData
533		self.writer.file = BytesIO()
534		for tag in self.tags:
535			self.writer[tag] = self.font.getTableData(tag)
536		self.writer.close()
537		self.assertEqual(expected, self.writer.length)
538		self.assertEqual(expected, self.writer.file.tell())
539
540	def test_calcTotalSize_with_metaData_and_privData(self):
541		metaDataLength = (len(self.compressed_metadata) + 3) & ~3
542		expected = self.length + metaDataLength + len(self.privData)
543		flavorData = self.writer.flavorData = WOFF2FlavorData()
544		flavorData.metaData = self.xml_metadata
545		flavorData.privData = self.privData
546		self.writer.file = BytesIO()
547		for tag in self.tags:
548			self.writer[tag] = self.font.getTableData(tag)
549		self.writer.close()
550		self.assertEqual(expected, self.writer.length)
551		self.assertEqual(expected, self.writer.file.tell())
552
553	def test_getVersion(self):
554		# no version
555		self.assertEqual((0, 0), self.writer._getVersion())
556		# version from head.fontRevision
557		fontRevision = self.font['head'].fontRevision
558		versionTuple = tuple(int(i) for i in str(fontRevision).split("."))
559		entry = self.writer.tables['head'] = ttLib.newTable('head')
560		entry.data = self.font.getTableData('head')
561		self.assertEqual(versionTuple, self.writer._getVersion())
562		# version from writer.flavorData
563		flavorData = self.writer.flavorData = WOFF2FlavorData()
564		flavorData.majorVersion, flavorData.minorVersion = (10, 11)
565		self.assertEqual((10, 11), self.writer._getVersion())
566
567	def test_hmtx_trasform(self):
568		tableTransforms = {"glyf", "loca", "hmtx"}
569
570		writer = WOFF2Writer(BytesIO(), self.numTables, self.font.sfntVersion)
571		writer.flavorData = WOFF2FlavorData(transformedTables=tableTransforms)
572
573		for tag in self.tags:
574			writer[tag] = self.font.getTableData(tag)
575		writer.close()
576
577		# enabling hmtx transform has no effect when font has no glyf table
578		self.assertEqual(writer.file.getvalue(), CFF_WOFF2.getvalue())
579
580	def test_no_transforms(self):
581		writer = WOFF2Writer(BytesIO(), self.numTables, self.font.sfntVersion)
582		writer.flavorData = WOFF2FlavorData(transformedTables=())
583
584		for tag in self.tags:
585			writer[tag] = self.font.getTableData(tag)
586		writer.close()
587
588		# transforms settings have no effect when font is CFF-flavored, since
589		# all the current transforms only apply to TrueType-flavored fonts.
590		self.assertEqual(writer.file.getvalue(), CFF_WOFF2.getvalue())
591
592class WOFF2WriterTTFTest(WOFF2WriterTest):
593
594	@classmethod
595	def setUpClass(cls):
596		cls.font = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False, flavor="woff2")
597		cls.font.importXML(TTX)
598		cls.tags = sorted(t for t in cls.font.keys() if t != 'GlyphOrder')
599		cls.numTables = len(cls.tags)
600		cls.file = BytesIO(TT_WOFF2.getvalue())
601		cls.file.seek(0, 2)
602		cls.length = (cls.file.tell() + 3) & ~3
603		cls.setUpFlavorData()
604
605	def test_normaliseGlyfAndLoca(self):
606		normTables = {}
607		for tag in ('head', 'loca', 'glyf'):
608			normTables[tag] = normalise_table(self.font, tag, padding=4)
609		for tag in self.tags:
610			tableData = self.font.getTableData(tag)
611			self.writer[tag] = tableData
612			if tag in normTables:
613				self.assertNotEqual(tableData, normTables[tag])
614		self.writer._normaliseGlyfAndLoca(padding=4)
615		self.writer._setHeadTransformFlag()
616		for tag in normTables:
617			self.assertEqual(self.writer.tables[tag].data, normTables[tag])
618
619	def test_hmtx_trasform(self):
620		tableTransforms = {"glyf", "loca", "hmtx"}
621
622		writer = WOFF2Writer(BytesIO(), self.numTables, self.font.sfntVersion)
623		writer.flavorData = WOFF2FlavorData(transformedTables=tableTransforms)
624
625		for tag in self.tags:
626			writer[tag] = self.font.getTableData(tag)
627		writer.close()
628
629		length = len(writer.file.getvalue())
630
631		# enabling optional hmtx transform shaves off a few bytes
632		self.assertLess(length, len(TT_WOFF2.getvalue()))
633
634	def test_no_transforms(self):
635		writer = WOFF2Writer(BytesIO(), self.numTables, self.font.sfntVersion)
636		writer.flavorData = WOFF2FlavorData(transformedTables=())
637
638		for tag in self.tags:
639			writer[tag] = self.font.getTableData(tag)
640		writer.close()
641
642		self.assertNotEqual(writer.file.getvalue(), TT_WOFF2.getvalue())
643
644		writer.file.seek(0)
645		reader = WOFF2Reader(writer.file)
646		self.assertEqual(len(reader.flavorData.transformedTables), 0)
647
648
649class WOFF2LocaTableTest(unittest.TestCase):
650
651	def setUp(self):
652		self.font = font = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False)
653		font['head'] = ttLib.newTable('head')
654		font['loca'] = WOFF2LocaTable()
655		font['glyf'] = WOFF2GlyfTable()
656
657	def test_compile_short_loca(self):
658		locaTable = self.font['loca']
659		locaTable.set(list(range(0, 0x20000, 2)))
660		self.font['glyf'].indexFormat = 0
661		locaData = locaTable.compile(self.font)
662		self.assertEqual(len(locaData), 0x20000)
663
664	def test_compile_short_loca_overflow(self):
665		locaTable = self.font['loca']
666		locaTable.set(list(range(0x20000 + 1)))
667		self.font['glyf'].indexFormat = 0
668		with self.assertRaisesRegex(
669				ttLib.TTLibError, "indexFormat is 0 but local offsets > 0x20000"):
670			locaTable.compile(self.font)
671
672	def test_compile_short_loca_not_multiples_of_2(self):
673		locaTable = self.font['loca']
674		locaTable.set([1, 3, 5, 7])
675		self.font['glyf'].indexFormat = 0
676		with self.assertRaisesRegex(ttLib.TTLibError, "offsets not multiples of 2"):
677			locaTable.compile(self.font)
678
679	def test_compile_long_loca(self):
680		locaTable = self.font['loca']
681		locaTable.set(list(range(0x20001)))
682		self.font['glyf'].indexFormat = 1
683		locaData = locaTable.compile(self.font)
684		self.assertEqual(len(locaData), 0x20001 * 4)
685
686	def test_compile_set_indexToLocFormat_0(self):
687		locaTable = self.font['loca']
688		# offsets are all multiples of 2 and max length is < 0x10000
689		locaTable.set(list(range(0, 0x20000, 2)))
690		locaTable.compile(self.font)
691		newIndexFormat = self.font['head'].indexToLocFormat
692		self.assertEqual(0, newIndexFormat)
693
694	def test_compile_set_indexToLocFormat_1(self):
695		locaTable = self.font['loca']
696		# offsets are not multiples of 2
697		locaTable.set(list(range(10)))
698		locaTable.compile(self.font)
699		newIndexFormat = self.font['head'].indexToLocFormat
700		self.assertEqual(1, newIndexFormat)
701		# max length is >= 0x10000
702		locaTable.set(list(range(0, 0x20000 + 1, 2)))
703		locaTable.compile(self.font)
704		newIndexFormat = self.font['head'].indexToLocFormat
705		self.assertEqual(1, newIndexFormat)
706
707
708class WOFF2GlyfTableTest(unittest.TestCase):
709
710	@classmethod
711	def setUpClass(cls):
712		font = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False)
713		font.importXML(TTX)
714		cls.tables = {}
715		cls.transformedTags = ('maxp', 'head', 'loca', 'glyf')
716		for tag in reversed(cls.transformedTags):  # compile in inverse order
717			cls.tables[tag] = font.getTableData(tag)
718		infile = BytesIO(TT_WOFF2.getvalue())
719		reader = WOFF2Reader(infile)
720		cls.transformedGlyfData = reader.tables['glyf'].loadData(
721			reader.transformBuffer)
722		cls.glyphOrder = ['.notdef'] + ["glyph%.5d" % i for i in range(1, font['maxp'].numGlyphs)]
723
724	def setUp(self):
725		self.font = font = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False)
726		font.setGlyphOrder(self.glyphOrder)
727		font['head'] = ttLib.newTable('head')
728		font['maxp'] = ttLib.newTable('maxp')
729		font['loca'] = WOFF2LocaTable()
730		font['glyf'] = WOFF2GlyfTable()
731		for tag in self.transformedTags:
732			font[tag].decompile(self.tables[tag], font)
733
734	def test_reconstruct_glyf_padded_4(self):
735		glyfTable = WOFF2GlyfTable()
736		glyfTable.reconstruct(self.transformedGlyfData, self.font)
737		glyfTable.padding = 4
738		data = glyfTable.compile(self.font)
739		normGlyfData = normalise_table(self.font, 'glyf', glyfTable.padding)
740		self.assertEqual(normGlyfData, data)
741
742	def test_reconstruct_glyf_padded_2(self):
743		glyfTable = WOFF2GlyfTable()
744		glyfTable.reconstruct(self.transformedGlyfData, self.font)
745		glyfTable.padding = 2
746		data = glyfTable.compile(self.font)
747		normGlyfData = normalise_table(self.font, 'glyf', glyfTable.padding)
748		self.assertEqual(normGlyfData, data)
749
750	def test_reconstruct_glyf_unpadded(self):
751		glyfTable = WOFF2GlyfTable()
752		glyfTable.reconstruct(self.transformedGlyfData, self.font)
753		data = glyfTable.compile(self.font)
754		self.assertEqual(self.tables['glyf'], data)
755
756	def test_reconstruct_glyf_incorrect_glyphOrder(self):
757		glyfTable = WOFF2GlyfTable()
758		badGlyphOrder = self.font.getGlyphOrder()[:-1]
759		self.font.setGlyphOrder(badGlyphOrder)
760		with self.assertRaisesRegex(ttLib.TTLibError, "incorrect glyphOrder"):
761			glyfTable.reconstruct(self.transformedGlyfData, self.font)
762
763	def test_reconstruct_glyf_missing_glyphOrder(self):
764		glyfTable = WOFF2GlyfTable()
765		del self.font.glyphOrder
766		numGlyphs = self.font['maxp'].numGlyphs
767		del self.font['maxp']
768		glyfTable.reconstruct(self.transformedGlyfData, self.font)
769		expected = [".notdef"]
770		expected.extend(["glyph%.5d" % i for i in range(1, numGlyphs)])
771		self.assertEqual(expected, glyfTable.glyphOrder)
772
773	def test_reconstruct_loca_padded_4(self):
774		locaTable = self.font['loca'] = WOFF2LocaTable()
775		glyfTable = self.font['glyf'] = WOFF2GlyfTable()
776		glyfTable.reconstruct(self.transformedGlyfData, self.font)
777		glyfTable.padding = 4
778		glyfTable.compile(self.font)
779		data = locaTable.compile(self.font)
780		normLocaData = normalise_table(self.font, 'loca', glyfTable.padding)
781		self.assertEqual(normLocaData, data)
782
783	def test_reconstruct_loca_padded_2(self):
784		locaTable = self.font['loca'] = WOFF2LocaTable()
785		glyfTable = self.font['glyf'] = WOFF2GlyfTable()
786		glyfTable.reconstruct(self.transformedGlyfData, self.font)
787		glyfTable.padding = 2
788		glyfTable.compile(self.font)
789		data = locaTable.compile(self.font)
790		normLocaData = normalise_table(self.font, 'loca', glyfTable.padding)
791		self.assertEqual(normLocaData, data)
792
793	def test_reconstruct_loca_unpadded(self):
794		locaTable = self.font['loca'] = WOFF2LocaTable()
795		glyfTable = self.font['glyf'] = WOFF2GlyfTable()
796		glyfTable.reconstruct(self.transformedGlyfData, self.font)
797		glyfTable.compile(self.font)
798		data = locaTable.compile(self.font)
799		self.assertEqual(self.tables['loca'], data)
800
801	def test_reconstruct_glyf_header_not_enough_data(self):
802		with self.assertRaisesRegex(ttLib.TTLibError, "not enough 'glyf' data"):
803			WOFF2GlyfTable().reconstruct(b"", self.font)
804
805	def test_reconstruct_glyf_table_incorrect_size(self):
806		msg = "incorrect size of transformed 'glyf'"
807		with self.assertRaisesRegex(ttLib.TTLibError, msg):
808			WOFF2GlyfTable().reconstruct(self.transformedGlyfData + b"\x00", self.font)
809		with self.assertRaisesRegex(ttLib.TTLibError, msg):
810			WOFF2GlyfTable().reconstruct(self.transformedGlyfData[:-1], self.font)
811
812	def test_transform_glyf(self):
813		glyfTable = self.font['glyf']
814		data = glyfTable.transform(self.font)
815		self.assertEqual(self.transformedGlyfData, data)
816
817	def test_roundtrip_glyf_reconstruct_and_transform(self):
818		glyfTable = WOFF2GlyfTable()
819		glyfTable.reconstruct(self.transformedGlyfData, self.font)
820		data = glyfTable.transform(self.font)
821		self.assertEqual(self.transformedGlyfData, data)
822
823	def test_roundtrip_glyf_transform_and_reconstruct(self):
824		glyfTable = self.font['glyf']
825		transformedData = glyfTable.transform(self.font)
826		newGlyfTable = WOFF2GlyfTable()
827		newGlyfTable.reconstruct(transformedData, self.font)
828		newGlyfTable.padding = 4
829		reconstructedData = newGlyfTable.compile(self.font)
830		normGlyfData = normalise_table(self.font, 'glyf', newGlyfTable.padding)
831		self.assertEqual(normGlyfData, reconstructedData)
832
833
834@pytest.fixture(scope="module")
835def fontfile():
836
837	class Glyph(object):
838		def __init__(self, empty=False, **kwargs):
839			if not empty:
840				self.draw = partial(self.drawRect, **kwargs)
841			else:
842				self.draw = lambda pen: None
843
844		@staticmethod
845		def drawRect(pen, xMin, xMax):
846			pen.moveTo((xMin, 0))
847			pen.lineTo((xMin, 1000))
848			pen.lineTo((xMax, 1000))
849			pen.lineTo((xMax, 0))
850			pen.closePath()
851
852	class CompositeGlyph(object):
853		def __init__(self, components):
854			self.components = components
855
856		def draw(self, pen):
857			for baseGlyph, (offsetX, offsetY) in self.components:
858				pen.addComponent(baseGlyph, (1, 0, 0, 1, offsetX, offsetY))
859
860	fb = fontBuilder.FontBuilder(unitsPerEm=1000, isTTF=True)
861	fb.setupGlyphOrder(
862		[".notdef", "space", "A", "acutecomb", "Aacute", "zero", "one", "two"]
863	)
864	fb.setupCharacterMap(
865		{
866			0x20: "space",
867			0x41: "A",
868			0x0301: "acutecomb",
869			0xC1: "Aacute",
870			0x30: "zero",
871			0x31: "one",
872			0x32: "two",
873		}
874	)
875	fb.setupHorizontalMetrics(
876		{
877			".notdef": (500, 50),
878			"space": (600, 0),
879			"A": (550, 40),
880			"acutecomb": (0, -40),
881			"Aacute": (550, 40),
882			"zero": (500, 30),
883			"one": (500, 50),
884			"two": (500, 40),
885		}
886	)
887	fb.setupHorizontalHeader(ascent=1000, descent=-200)
888
889	srcGlyphs = {
890		".notdef": Glyph(xMin=50, xMax=450),
891		"space": Glyph(empty=True),
892		"A": Glyph(xMin=40, xMax=510),
893		"acutecomb": Glyph(xMin=-40, xMax=60),
894		"Aacute": CompositeGlyph([("A", (0, 0)), ("acutecomb", (200, 0))]),
895		"zero": Glyph(xMin=30, xMax=470),
896		"one": Glyph(xMin=50, xMax=450),
897		"two": Glyph(xMin=40, xMax=460),
898	}
899	pen = TTGlyphPen(srcGlyphs)
900	glyphSet = {}
901	for glyphName, glyph in srcGlyphs.items():
902		glyph.draw(pen)
903		glyphSet[glyphName] = pen.glyph()
904	fb.setupGlyf(glyphSet)
905
906	fb.setupNameTable(
907		{
908			"familyName": "TestWOFF2",
909			"styleName": "Regular",
910			"uniqueFontIdentifier": "TestWOFF2 Regular; Version 1.000; ABCD",
911			"fullName": "TestWOFF2 Regular",
912			"version": "Version 1.000",
913			"psName": "TestWOFF2-Regular",
914		}
915	)
916	fb.setupOS2()
917	fb.setupPost()
918
919	buf = BytesIO()
920	fb.save(buf)
921	buf.seek(0)
922
923	assert fb.font["maxp"].numGlyphs == 8
924	assert fb.font["hhea"].numberOfHMetrics == 6
925	for glyphName in fb.font.getGlyphOrder():
926		xMin = getattr(fb.font["glyf"][glyphName], "xMin", 0)
927		assert xMin == fb.font["hmtx"][glyphName][1]
928
929	return buf
930
931
932@pytest.fixture
933def ttFont(fontfile):
934	return ttLib.TTFont(fontfile, recalcBBoxes=False, recalcTimestamp=False)
935
936
937class WOFF2HmtxTableTest(object):
938	def test_transform_no_sidebearings(self, ttFont):
939		hmtxTable = WOFF2HmtxTable()
940		hmtxTable.metrics = ttFont["hmtx"].metrics
941
942		data = hmtxTable.transform(ttFont)
943
944		assert data == (
945			b"\x03"  # 00000011 | bits 0 and 1 are set (no sidebearings arrays)
946
947			# advanceWidthArray
948			b'\x01\xf4'  # .notdef: 500
949			b'\x02X'     # space: 600
950			b'\x02&'     # A: 550
951			b'\x00\x00'  # acutecomb: 0
952			b'\x02&'     # Aacute: 550
953			b'\x01\xf4'  # zero: 500
954		)
955
956	def test_transform_proportional_sidebearings(self, ttFont):
957		hmtxTable = WOFF2HmtxTable()
958		metrics = ttFont["hmtx"].metrics
959		# force one of the proportional glyphs to have its left sidebearing be
960		# different from its xMin (40)
961		metrics["A"] = (550, 39)
962		hmtxTable.metrics = metrics
963
964		assert ttFont["glyf"]["A"].xMin != metrics["A"][1]
965
966		data = hmtxTable.transform(ttFont)
967
968		assert data == (
969			b"\x02"  # 00000010 | bits 0 unset: explicit proportional sidebearings
970
971			# advanceWidthArray
972			b'\x01\xf4'  # .notdef: 500
973			b'\x02X'     # space: 600
974			b'\x02&'     # A: 550
975			b'\x00\x00'  # acutecomb: 0
976			b'\x02&'     # Aacute: 550
977			b'\x01\xf4'  # zero: 500
978
979			# lsbArray
980			b'\x002'     # .notdef: 50
981			b'\x00\x00'  # space: 0
982			b"\x00'"     # A: 39 (xMin: 40)
983			b'\xff\xd8'  # acutecomb: -40
984			b'\x00('     # Aacute: 40
985			b'\x00\x1e'  # zero: 30
986		)
987
988	def test_transform_monospaced_sidebearings(self, ttFont):
989		hmtxTable = WOFF2HmtxTable()
990		metrics = ttFont["hmtx"].metrics
991		hmtxTable.metrics = metrics
992
993		# force one of the monospaced glyphs at the end of hmtx table to have
994		# its xMin different from its left sidebearing (50)
995		ttFont["glyf"]["one"].xMin = metrics["one"][1] + 1
996
997		data = hmtxTable.transform(ttFont)
998
999		assert data == (
1000			b"\x01"  # 00000001 | bits 1 unset: explicit monospaced sidebearings
1001
1002			# advanceWidthArray
1003			b'\x01\xf4'  # .notdef: 500
1004			b'\x02X'     # space: 600
1005			b'\x02&'     # A: 550
1006			b'\x00\x00'  # acutecomb: 0
1007			b'\x02&'     # Aacute: 550
1008			b'\x01\xf4'  # zero: 500
1009
1010			# leftSideBearingArray
1011			b'\x002'     # one: 50 (xMin: 51)
1012			b'\x00('     # two: 40
1013		)
1014
1015	def test_transform_not_applicable(self, ttFont):
1016		hmtxTable = WOFF2HmtxTable()
1017		metrics = ttFont["hmtx"].metrics
1018		# force both a proportional and monospaced glyph to have sidebearings
1019		# different from the respective xMin coordinates
1020		metrics["A"] = (550, 39)
1021		metrics["one"] = (500, 51)
1022		hmtxTable.metrics = metrics
1023
1024		# 'None' signals to fall back using untransformed hmtx table data
1025		assert hmtxTable.transform(ttFont) is None
1026
1027	def test_reconstruct_no_sidebearings(self, ttFont):
1028		hmtxTable = WOFF2HmtxTable()
1029
1030		data = (
1031			b"\x03"  # 00000011 | bits 0 and 1 are set (no sidebearings arrays)
1032
1033			# advanceWidthArray
1034			b'\x01\xf4'  # .notdef: 500
1035			b'\x02X'     # space: 600
1036			b'\x02&'     # A: 550
1037			b'\x00\x00'  # acutecomb: 0
1038			b'\x02&'     # Aacute: 550
1039			b'\x01\xf4'  # zero: 500
1040		)
1041
1042		hmtxTable.reconstruct(data, ttFont)
1043
1044		assert hmtxTable.metrics == {
1045			".notdef": (500, 50),
1046			"space": (600, 0),
1047			"A": (550, 40),
1048			"acutecomb": (0, -40),
1049			"Aacute": (550, 40),
1050			"zero": (500, 30),
1051			"one": (500, 50),
1052			"two": (500, 40),
1053		}
1054
1055	def test_reconstruct_proportional_sidebearings(self, ttFont):
1056		hmtxTable = WOFF2HmtxTable()
1057
1058		data = (
1059			b"\x02"  # 00000010 | bits 0 unset: explicit proportional sidebearings
1060
1061			# advanceWidthArray
1062			b'\x01\xf4'  # .notdef: 500
1063			b'\x02X'     # space: 600
1064			b'\x02&'     # A: 550
1065			b'\x00\x00'  # acutecomb: 0
1066			b'\x02&'     # Aacute: 550
1067			b'\x01\xf4'  # zero: 500
1068
1069			# lsbArray
1070			b'\x002'     # .notdef: 50
1071			b'\x00\x00'  # space: 0
1072			b"\x00'"     # A: 39 (xMin: 40)
1073			b'\xff\xd8'  # acutecomb: -40
1074			b'\x00('     # Aacute: 40
1075			b'\x00\x1e'  # zero: 30
1076		)
1077
1078		hmtxTable.reconstruct(data, ttFont)
1079
1080		assert hmtxTable.metrics == {
1081			".notdef": (500, 50),
1082			"space": (600, 0),
1083			"A": (550, 39),
1084			"acutecomb": (0, -40),
1085			"Aacute": (550, 40),
1086			"zero": (500, 30),
1087			"one": (500, 50),
1088			"two": (500, 40),
1089		}
1090
1091		assert ttFont["glyf"]["A"].xMin == 40
1092
1093	def test_reconstruct_monospaced_sidebearings(self, ttFont):
1094		hmtxTable = WOFF2HmtxTable()
1095
1096		data = (
1097			b"\x01"  # 00000001 | bits 1 unset: explicit monospaced sidebearings
1098
1099			# advanceWidthArray
1100			b'\x01\xf4'  # .notdef: 500
1101			b'\x02X'     # space: 600
1102			b'\x02&'     # A: 550
1103			b'\x00\x00'  # acutecomb: 0
1104			b'\x02&'     # Aacute: 550
1105			b'\x01\xf4'  # zero: 500
1106
1107			# leftSideBearingArray
1108			b'\x003'     # one: 51 (xMin: 50)
1109			b'\x00('     # two: 40
1110		)
1111
1112		hmtxTable.reconstruct(data, ttFont)
1113
1114		assert hmtxTable.metrics == {
1115			".notdef": (500, 50),
1116			"space": (600, 0),
1117			"A": (550, 40),
1118			"acutecomb": (0, -40),
1119			"Aacute": (550, 40),
1120			"zero": (500, 30),
1121			"one": (500, 51),
1122			"two": (500, 40),
1123		}
1124
1125		assert ttFont["glyf"]["one"].xMin == 50
1126
1127	def test_reconstruct_flags_reserved_bits(self):
1128		hmtxTable = WOFF2HmtxTable()
1129
1130		with pytest.raises(
1131			ttLib.TTLibError, match="Bits 2-7 of 'hmtx' flags are reserved"
1132		):
1133			hmtxTable.reconstruct(b"\xFF", ttFont=None)
1134
1135	def test_reconstruct_flags_required_bits(self):
1136		hmtxTable = WOFF2HmtxTable()
1137
1138		with pytest.raises(ttLib.TTLibError, match="either bits 0 or 1 .* must set"):
1139			hmtxTable.reconstruct(b"\x00", ttFont=None)
1140
1141	def test_reconstruct_too_much_data(self, ttFont):
1142		ttFont["hhea"].numberOfHMetrics = 2
1143		data = b'\x03\x01\xf4\x02X\x02&'
1144		hmtxTable = WOFF2HmtxTable()
1145
1146		with pytest.raises(ttLib.TTLibError, match="too much 'hmtx' table data"):
1147			hmtxTable.reconstruct(data, ttFont)
1148
1149
1150class WOFF2RoundtripTest(object):
1151	@staticmethod
1152	def roundtrip(infile):
1153		infile.seek(0)
1154		ttFont = ttLib.TTFont(infile, recalcBBoxes=False, recalcTimestamp=False)
1155		outfile = BytesIO()
1156		ttFont.save(outfile)
1157		return outfile, ttFont
1158
1159	def test_roundtrip_default_transforms(self, ttFont):
1160		ttFont.flavor = "woff2"
1161		# ttFont.flavorData = None
1162		tmp = BytesIO()
1163		ttFont.save(tmp)
1164
1165		tmp2, ttFont2 = self.roundtrip(tmp)
1166
1167		assert tmp.getvalue() == tmp2.getvalue()
1168		assert ttFont2.reader.flavorData.transformedTables == {"glyf", "loca"}
1169
1170	def test_roundtrip_no_transforms(self, ttFont):
1171		ttFont.flavor = "woff2"
1172		ttFont.flavorData = WOFF2FlavorData(transformedTables=[])
1173		tmp = BytesIO()
1174		ttFont.save(tmp)
1175
1176		tmp2, ttFont2 = self.roundtrip(tmp)
1177
1178		assert tmp.getvalue() == tmp2.getvalue()
1179		assert not ttFont2.reader.flavorData.transformedTables
1180
1181	def test_roundtrip_all_transforms(self, ttFont):
1182		ttFont.flavor = "woff2"
1183		ttFont.flavorData = WOFF2FlavorData(transformedTables=["glyf", "loca", "hmtx"])
1184		tmp = BytesIO()
1185		ttFont.save(tmp)
1186
1187		tmp2, ttFont2 = self.roundtrip(tmp)
1188
1189		assert tmp.getvalue() == tmp2.getvalue()
1190		assert ttFont2.reader.flavorData.transformedTables == {"glyf", "loca", "hmtx"}
1191
1192	def test_roundtrip_only_hmtx_no_glyf_transform(self, ttFont):
1193		ttFont.flavor = "woff2"
1194		ttFont.flavorData = WOFF2FlavorData(transformedTables=["hmtx"])
1195		tmp = BytesIO()
1196		ttFont.save(tmp)
1197
1198		tmp2, ttFont2 = self.roundtrip(tmp)
1199
1200		assert tmp.getvalue() == tmp2.getvalue()
1201		assert ttFont2.reader.flavorData.transformedTables == {"hmtx"}
1202
1203
1204class MainTest(object):
1205
1206	@staticmethod
1207	def make_ttf(tmpdir):
1208		ttFont = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False)
1209		ttFont.importXML(TTX)
1210		filename = str(tmpdir / "TestTTF-Regular.ttf")
1211		ttFont.save(filename)
1212		return filename
1213
1214	def test_compress_ttf(self, tmpdir):
1215		input_file = self.make_ttf(tmpdir)
1216
1217		assert woff2.main(["compress", input_file]) is None
1218
1219		assert (tmpdir / "TestTTF-Regular.woff2").check(file=True)
1220
1221	def test_compress_ttf_no_glyf_transform(self, tmpdir):
1222		input_file = self.make_ttf(tmpdir)
1223
1224		assert woff2.main(["compress", "--no-glyf-transform", input_file]) is None
1225
1226		assert (tmpdir / "TestTTF-Regular.woff2").check(file=True)
1227
1228	def test_compress_ttf_hmtx_transform(self, tmpdir):
1229		input_file = self.make_ttf(tmpdir)
1230
1231		assert woff2.main(["compress", "--hmtx-transform", input_file]) is None
1232
1233		assert (tmpdir / "TestTTF-Regular.woff2").check(file=True)
1234
1235	def test_compress_ttf_no_glyf_transform_hmtx_transform(self, tmpdir):
1236		input_file = self.make_ttf(tmpdir)
1237
1238		assert woff2.main(
1239			["compress", "--no-glyf-transform", "--hmtx-transform", input_file]
1240		) is None
1241
1242		assert (tmpdir / "TestTTF-Regular.woff2").check(file=True)
1243
1244	def test_compress_output_file(self, tmpdir):
1245		input_file = self.make_ttf(tmpdir)
1246		output_file = tmpdir / "TestTTF.woff2"
1247
1248		assert woff2.main(
1249			["compress", "-o", str(output_file), str(input_file)]
1250		) is None
1251
1252		assert output_file.check(file=True)
1253
1254	def test_compress_otf(self, tmpdir):
1255		ttFont = ttLib.TTFont(recalcBBoxes=False, recalcTimestamp=False)
1256		ttFont.importXML(OTX)
1257		input_file = str(tmpdir / "TestOTF-Regular.otf")
1258		ttFont.save(input_file)
1259
1260		assert woff2.main(["compress", input_file]) is None
1261
1262		assert (tmpdir / "TestOTF-Regular.woff2").check(file=True)
1263
1264	def test_recompress_woff2_keeps_flavorData(self, tmpdir):
1265		woff2_font = ttLib.TTFont(BytesIO(TT_WOFF2.getvalue()))
1266		woff2_font.flavorData.privData = b"FOOBAR"
1267		woff2_file = tmpdir / "TestTTF-Regular.woff2"
1268		woff2_font.save(str(woff2_file))
1269
1270		assert woff2_font.flavorData.transformedTables == {"glyf", "loca"}
1271
1272		woff2.main(["compress", "--hmtx-transform", str(woff2_file)])
1273
1274		output_file = tmpdir / "TestTTF-Regular#1.woff2"
1275		assert output_file.check(file=True)
1276
1277		new_woff2_font = ttLib.TTFont(str(output_file))
1278
1279		assert new_woff2_font.flavorData.transformedTables == {"glyf", "loca", "hmtx"}
1280		assert new_woff2_font.flavorData.privData == b"FOOBAR"
1281
1282	def test_decompress_ttf(self, tmpdir):
1283		input_file = tmpdir / "TestTTF-Regular.woff2"
1284		input_file.write_binary(TT_WOFF2.getvalue())
1285
1286		assert woff2.main(["decompress", str(input_file)]) is None
1287
1288		assert (tmpdir / "TestTTF-Regular.ttf").check(file=True)
1289
1290	def test_decompress_otf(self, tmpdir):
1291		input_file = tmpdir / "TestTTF-Regular.woff2"
1292		input_file.write_binary(CFF_WOFF2.getvalue())
1293
1294		assert woff2.main(["decompress", str(input_file)]) is None
1295
1296		assert (tmpdir / "TestTTF-Regular.otf").check(file=True)
1297
1298	def test_decompress_output_file(self, tmpdir):
1299		input_file = tmpdir / "TestTTF-Regular.woff2"
1300		input_file.write_binary(TT_WOFF2.getvalue())
1301		output_file = tmpdir / "TestTTF.ttf"
1302
1303		assert woff2.main(
1304			["decompress", "-o", str(output_file), str(input_file)]
1305		) is None
1306
1307		assert output_file.check(file=True)
1308
1309	def test_no_subcommand_show_help(self, capsys):
1310		with pytest.raises(SystemExit):
1311			woff2.main(["--help"])
1312
1313		captured = capsys.readouterr()
1314		assert "usage: fonttools ttLib.woff2" in captured.out
1315
1316
1317class Base128Test(unittest.TestCase):
1318
1319	def test_unpackBase128(self):
1320		self.assertEqual(unpackBase128(b'\x3f\x00\x00'), (63, b"\x00\x00"))
1321		self.assertEqual(unpackBase128(b'\x8f\xff\xff\xff\x7f')[0], 4294967295)
1322
1323		self.assertRaisesRegex(
1324			ttLib.TTLibError,
1325			"UIntBase128 value must not start with leading zeros",
1326			unpackBase128, b'\x80\x80\x3f')
1327
1328		self.assertRaisesRegex(
1329			ttLib.TTLibError,
1330			"UIntBase128-encoded sequence is longer than 5 bytes",
1331			unpackBase128, b'\x8f\xff\xff\xff\xff\x7f')
1332
1333		self.assertRaisesRegex(
1334			ttLib.TTLibError,
1335			r"UIntBase128 value exceeds 2\*\*32-1",
1336			unpackBase128, b'\x90\x80\x80\x80\x00')
1337
1338		self.assertRaisesRegex(
1339			ttLib.TTLibError,
1340			"not enough data to unpack UIntBase128",
1341			unpackBase128, b'')
1342
1343	def test_base128Size(self):
1344		self.assertEqual(base128Size(0), 1)
1345		self.assertEqual(base128Size(24567), 3)
1346		self.assertEqual(base128Size(2**32-1), 5)
1347
1348	def test_packBase128(self):
1349		self.assertEqual(packBase128(63), b"\x3f")
1350		self.assertEqual(packBase128(2**32-1), b'\x8f\xff\xff\xff\x7f')
1351		self.assertRaisesRegex(
1352			ttLib.TTLibError,
1353			r"UIntBase128 format requires 0 <= integer <= 2\*\*32-1",
1354			packBase128, 2**32+1)
1355		self.assertRaisesRegex(
1356			ttLib.TTLibError,
1357			r"UIntBase128 format requires 0 <= integer <= 2\*\*32-1",
1358			packBase128, -1)
1359
1360
1361class UShort255Test(unittest.TestCase):
1362
1363	def test_unpack255UShort(self):
1364		self.assertEqual(unpack255UShort(bytechr(252))[0], 252)
1365		# some numbers (e.g. 506) can have multiple encodings
1366		self.assertEqual(
1367			unpack255UShort(struct.pack(b"BB", 254, 0))[0], 506)
1368		self.assertEqual(
1369			unpack255UShort(struct.pack(b"BB", 255, 253))[0], 506)
1370		self.assertEqual(
1371			unpack255UShort(struct.pack(b"BBB", 253, 1, 250))[0], 506)
1372
1373		self.assertRaisesRegex(
1374			ttLib.TTLibError,
1375			"not enough data to unpack 255UInt16",
1376			unpack255UShort, struct.pack(b"BB", 253, 0))
1377
1378		self.assertRaisesRegex(
1379			ttLib.TTLibError,
1380			"not enough data to unpack 255UInt16",
1381			unpack255UShort, struct.pack(b"B", 254))
1382
1383		self.assertRaisesRegex(
1384			ttLib.TTLibError,
1385			"not enough data to unpack 255UInt16",
1386			unpack255UShort, struct.pack(b"B", 255))
1387
1388	def test_pack255UShort(self):
1389		self.assertEqual(pack255UShort(252), b'\xfc')
1390		self.assertEqual(pack255UShort(505), b'\xff\xfc')
1391		self.assertEqual(pack255UShort(506), b'\xfe\x00')
1392		self.assertEqual(pack255UShort(762), b'\xfd\x02\xfa')
1393
1394		self.assertRaisesRegex(
1395			ttLib.TTLibError,
1396			"255UInt16 format requires 0 <= integer <= 65535",
1397			pack255UShort, -1)
1398
1399		self.assertRaisesRegex(
1400			ttLib.TTLibError,
1401			"255UInt16 format requires 0 <= integer <= 65535",
1402			pack255UShort, 0xFFFF+1)
1403
1404
1405if __name__ == "__main__":
1406	import sys
1407	sys.exit(unittest.main())
1408