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