1from __future__ import print_function, division, absolute_import
2from fontTools.misc.py23 import *
3from fontTools.misc.testTools import parseXML
4from fontTools.misc.timeTools import timestampSinceEpoch
5from fontTools.ttLib import TTFont, TTLibError
6from fontTools import ttx
7import getopt
8import logging
9import os
10import shutil
11import sys
12import tempfile
13import unittest
14
15import pytest
16
17try:
18    import zopfli
19except ImportError:
20    zopfli = None
21try:
22    import brotli
23except ImportError:
24    brotli = None
25
26
27class TTXTest(unittest.TestCase):
28
29    def __init__(self, methodName):
30        unittest.TestCase.__init__(self, methodName)
31        # Python 3 renamed assertRaisesRegexp to assertRaisesRegex,
32        # and fires deprecation warnings if a program uses the old name.
33        if not hasattr(self, "assertRaisesRegex"):
34            self.assertRaisesRegex = self.assertRaisesRegexp
35
36    def setUp(self):
37        self.tempdir = None
38        self.num_tempfiles = 0
39
40    def tearDown(self):
41        if self.tempdir:
42            shutil.rmtree(self.tempdir)
43
44    @staticmethod
45    def getpath(testfile):
46        path, _ = os.path.split(__file__)
47        return os.path.join(path, "data", testfile)
48
49    def temp_dir(self):
50        if not self.tempdir:
51            self.tempdir = tempfile.mkdtemp()
52
53    def temp_font(self, font_path, file_name):
54        self.temp_dir()
55        temppath = os.path.join(self.tempdir, file_name)
56        shutil.copy2(font_path, temppath)
57        return temppath
58
59    @staticmethod
60    def read_file(file_path):
61        with open(file_path, "r", encoding="utf-8") as f:
62            return f.readlines()
63
64    # -----
65    # Tests
66    # -----
67
68    def test_parseOptions_no_args(self):
69        with self.assertRaises(getopt.GetoptError) as cm:
70            ttx.parseOptions([])
71        self.assertTrue(
72            "Must specify at least one input file" in str(cm.exception)
73        )
74
75    def test_parseOptions_invalid_path(self):
76        file_path = "invalid_font_path"
77        with self.assertRaises(getopt.GetoptError) as cm:
78            ttx.parseOptions([file_path])
79        self.assertTrue('File not found: "%s"' % file_path in str(cm.exception))
80
81    def test_parseOptions_font2ttx_1st_time(self):
82        file_name = "TestOTF.otf"
83        font_path = self.getpath(file_name)
84        temp_path = self.temp_font(font_path, file_name)
85        jobs, _ = ttx.parseOptions([temp_path])
86        self.assertEqual(jobs[0][0].__name__, "ttDump")
87        self.assertEqual(
88            jobs[0][1:],
89            (
90                os.path.join(self.tempdir, file_name),
91                os.path.join(self.tempdir, file_name.split(".")[0] + ".ttx"),
92            ),
93        )
94
95    def test_parseOptions_font2ttx_2nd_time(self):
96        file_name = "TestTTF.ttf"
97        font_path = self.getpath(file_name)
98        temp_path = self.temp_font(font_path, file_name)
99        _, _ = ttx.parseOptions([temp_path])  # this is NOT a mistake
100        jobs, _ = ttx.parseOptions([temp_path])
101        self.assertEqual(jobs[0][0].__name__, "ttDump")
102        self.assertEqual(
103            jobs[0][1:],
104            (
105                os.path.join(self.tempdir, file_name),
106                os.path.join(self.tempdir, file_name.split(".")[0] + "#1.ttx"),
107            ),
108        )
109
110    def test_parseOptions_ttx2font_1st_time(self):
111        file_name = "TestTTF.ttx"
112        font_path = self.getpath(file_name)
113        temp_path = self.temp_font(font_path, file_name)
114        jobs, _ = ttx.parseOptions([temp_path])
115        self.assertEqual(jobs[0][0].__name__, "ttCompile")
116        self.assertEqual(
117            jobs[0][1:],
118            (
119                os.path.join(self.tempdir, file_name),
120                os.path.join(self.tempdir, file_name.split(".")[0] + ".ttf"),
121            ),
122        )
123
124    def test_parseOptions_ttx2font_2nd_time(self):
125        file_name = "TestOTF.ttx"
126        font_path = self.getpath(file_name)
127        temp_path = self.temp_font(font_path, file_name)
128        _, _ = ttx.parseOptions([temp_path])  # this is NOT a mistake
129        jobs, _ = ttx.parseOptions([temp_path])
130        self.assertEqual(jobs[0][0].__name__, "ttCompile")
131        self.assertEqual(
132            jobs[0][1:],
133            (
134                os.path.join(self.tempdir, file_name),
135                os.path.join(self.tempdir, file_name.split(".")[0] + "#1.otf"),
136            ),
137        )
138
139    def test_parseOptions_multiple_fonts(self):
140        file_names = ["TestOTF.otf", "TestTTF.ttf"]
141        font_paths = [self.getpath(file_name) for file_name in file_names]
142        temp_paths = [
143            self.temp_font(font_path, file_name)
144            for font_path, file_name in zip(font_paths, file_names)
145        ]
146        jobs, _ = ttx.parseOptions(temp_paths)
147        for i in range(len(jobs)):
148            self.assertEqual(jobs[i][0].__name__, "ttDump")
149            self.assertEqual(
150                jobs[i][1:],
151                (
152                    os.path.join(self.tempdir, file_names[i]),
153                    os.path.join(
154                        self.tempdir, file_names[i].split(".")[0] + ".ttx"
155                    ),
156                ),
157            )
158
159    def test_parseOptions_mixed_files(self):
160        operations = ["ttDump", "ttCompile"]
161        extensions = [".ttx", ".ttf"]
162        file_names = ["TestOTF.otf", "TestTTF.ttx"]
163        font_paths = [self.getpath(file_name) for file_name in file_names]
164        temp_paths = [
165            self.temp_font(font_path, file_name)
166            for font_path, file_name in zip(font_paths, file_names)
167        ]
168        jobs, _ = ttx.parseOptions(temp_paths)
169        for i in range(len(jobs)):
170            self.assertEqual(jobs[i][0].__name__, operations[i])
171            self.assertEqual(
172                jobs[i][1:],
173                (
174                    os.path.join(self.tempdir, file_names[i]),
175                    os.path.join(
176                        self.tempdir,
177                        file_names[i].split(".")[0] + extensions[i],
178                    ),
179                ),
180            )
181
182    def test_parseOptions_splitTables(self):
183        file_name = "TestTTF.ttf"
184        font_path = self.getpath(file_name)
185        temp_path = self.temp_font(font_path, file_name)
186        args = ["-s", temp_path]
187
188        jobs, options = ttx.parseOptions(args)
189
190        ttx_file_path = jobs[0][2]
191        temp_folder = os.path.dirname(ttx_file_path)
192        self.assertTrue(options.splitTables)
193        self.assertTrue(os.path.exists(ttx_file_path))
194
195        ttx.process(jobs, options)
196
197        # Read the TTX file but strip the first two and the last lines:
198        # <?xml version="1.0" encoding="UTF-8"?>
199        # <ttFont sfntVersion="\x00\x01\x00\x00" ttLibVersion="3.22">
200        # ...
201        # </ttFont>
202        parsed_xml = parseXML(self.read_file(ttx_file_path)[2:-1])
203        for item in parsed_xml:
204            if not isinstance(item, tuple):
205                continue
206            # the tuple looks like this:
207            # (u'head', {u'src': u'TestTTF._h_e_a_d.ttx'}, [])
208            table_file_name = item[1].get("src")
209            table_file_path = os.path.join(temp_folder, table_file_name)
210            self.assertTrue(os.path.exists(table_file_path))
211
212    def test_parseOptions_splitGlyphs(self):
213        file_name = "TestTTF.ttf"
214        font_path = self.getpath(file_name)
215        temp_path = self.temp_font(font_path, file_name)
216        args = ["-g", temp_path]
217
218        jobs, options = ttx.parseOptions(args)
219
220        ttx_file_path = jobs[0][2]
221        temp_folder = os.path.dirname(ttx_file_path)
222        self.assertTrue(options.splitGlyphs)
223        # splitGlyphs also forces splitTables
224        self.assertTrue(options.splitTables)
225        self.assertTrue(os.path.exists(ttx_file_path))
226
227        ttx.process(jobs, options)
228
229        # Read the TTX file but strip the first two and the last lines:
230        # <?xml version="1.0" encoding="UTF-8"?>
231        # <ttFont sfntVersion="\x00\x01\x00\x00" ttLibVersion="3.22">
232        # ...
233        # </ttFont>
234        for item in parseXML(self.read_file(ttx_file_path)[2:-1]):
235            if not isinstance(item, tuple):
236                continue
237            # the tuple looks like this:
238            # (u'head', {u'src': u'TestTTF._h_e_a_d.ttx'}, [])
239            table_tag = item[0]
240            table_file_name = item[1].get("src")
241            table_file_path = os.path.join(temp_folder, table_file_name)
242            self.assertTrue(os.path.exists(table_file_path))
243            if table_tag != "glyf":
244                continue
245            # also strip the enclosing 'glyf' element
246            for item in parseXML(self.read_file(table_file_path)[4:-3]):
247                if not isinstance(item, tuple):
248                    continue
249                # glyphs without outline data only have 'name' attribute
250                glyph_file_name = item[1].get("src")
251                if glyph_file_name is not None:
252                    glyph_file_path = os.path.join(temp_folder, glyph_file_name)
253                    self.assertTrue(os.path.exists(glyph_file_path))
254
255    def test_guessFileType_ttf(self):
256        file_name = "TestTTF.ttf"
257        font_path = self.getpath(file_name)
258        self.assertEqual(ttx.guessFileType(font_path), "TTF")
259
260    def test_guessFileType_otf(self):
261        file_name = "TestOTF.otf"
262        font_path = self.getpath(file_name)
263        self.assertEqual(ttx.guessFileType(font_path), "OTF")
264
265    def test_guessFileType_woff(self):
266        file_name = "TestWOFF.woff"
267        font_path = self.getpath(file_name)
268        self.assertEqual(ttx.guessFileType(font_path), "WOFF")
269
270    def test_guessFileType_woff2(self):
271        file_name = "TestWOFF2.woff2"
272        font_path = self.getpath(file_name)
273        self.assertEqual(ttx.guessFileType(font_path), "WOFF2")
274
275    def test_guessFileType_ttc(self):
276        file_name = "TestTTC.ttc"
277        font_path = self.getpath(file_name)
278        self.assertEqual(ttx.guessFileType(font_path), "TTC")
279
280    def test_guessFileType_dfont(self):
281        file_name = "TestDFONT.dfont"
282        font_path = self.getpath(file_name)
283        self.assertEqual(ttx.guessFileType(font_path), "TTF")
284
285    def test_guessFileType_ttx_ttf(self):
286        file_name = "TestTTF.ttx"
287        font_path = self.getpath(file_name)
288        self.assertEqual(ttx.guessFileType(font_path), "TTX")
289
290    def test_guessFileType_ttx_otf(self):
291        file_name = "TestOTF.ttx"
292        font_path = self.getpath(file_name)
293        self.assertEqual(ttx.guessFileType(font_path), "OTX")
294
295    def test_guessFileType_ttx_bom(self):
296        file_name = "TestBOM.ttx"
297        font_path = self.getpath(file_name)
298        self.assertEqual(ttx.guessFileType(font_path), "TTX")
299
300    def test_guessFileType_ttx_no_sfntVersion(self):
301        file_name = "TestNoSFNT.ttx"
302        font_path = self.getpath(file_name)
303        self.assertEqual(ttx.guessFileType(font_path), "TTX")
304
305    def test_guessFileType_ttx_no_xml(self):
306        file_name = "TestNoXML.ttx"
307        font_path = self.getpath(file_name)
308        self.assertIsNone(ttx.guessFileType(font_path))
309
310    def test_guessFileType_invalid_path(self):
311        font_path = "invalid_font_path"
312        self.assertIsNone(ttx.guessFileType(font_path))
313
314
315# -----------------------
316# ttx.Options class tests
317# -----------------------
318
319
320def test_options_flag_h(capsys):
321    with pytest.raises(SystemExit):
322        ttx.Options([("-h", None)], 1)
323
324    out, err = capsys.readouterr()
325    assert "TTX -- From OpenType To XML And Back" in out
326
327
328def test_options_flag_version(capsys):
329    with pytest.raises(SystemExit):
330        ttx.Options([("--version", None)], 1)
331
332    out, err = capsys.readouterr()
333    version_list = out.split(".")
334    assert len(version_list) >= 3
335    assert version_list[0].isdigit()
336    assert version_list[1].isdigit()
337    assert version_list[2].strip().isdigit()
338
339
340def test_options_d_goodpath(tmpdir):
341    temp_dir_path = str(tmpdir)
342    tto = ttx.Options([("-d", temp_dir_path)], 1)
343    assert tto.outputDir == temp_dir_path
344
345
346def test_options_d_badpath():
347    with pytest.raises(getopt.GetoptError):
348        ttx.Options([("-d", "bogusdir")], 1)
349
350
351def test_options_o():
352    tto = ttx.Options([("-o", "testfile.ttx")], 1)
353    assert tto.outputFile == "testfile.ttx"
354
355
356def test_options_f():
357    tto = ttx.Options([("-f", "")], 1)
358    assert tto.overWrite is True
359
360
361def test_options_v():
362    tto = ttx.Options([("-v", "")], 1)
363    assert tto.verbose is True
364    assert tto.logLevel == logging.DEBUG
365
366
367def test_options_q():
368    tto = ttx.Options([("-q", "")], 1)
369    assert tto.quiet is True
370    assert tto.logLevel == logging.WARNING
371
372
373def test_options_l():
374    tto = ttx.Options([("-l", "")], 1)
375    assert tto.listTables is True
376
377
378def test_options_t_nopadding():
379    tto = ttx.Options([("-t", "CFF2")], 1)
380    assert len(tto.onlyTables) == 1
381    assert tto.onlyTables[0] == "CFF2"
382
383
384def test_options_t_withpadding():
385    tto = ttx.Options([("-t", "CFF")], 1)
386    assert len(tto.onlyTables) == 1
387    assert tto.onlyTables[0] == "CFF "
388
389
390def test_options_s():
391    tto = ttx.Options([("-s", "")], 1)
392    assert tto.splitTables is True
393    assert tto.splitGlyphs is False
394
395
396def test_options_g():
397    tto = ttx.Options([("-g", "")], 1)
398    assert tto.splitGlyphs is True
399    assert tto.splitTables is True
400
401
402def test_options_i():
403    tto = ttx.Options([("-i", "")], 1)
404    assert tto.disassembleInstructions is False
405
406
407def test_options_z_validoptions():
408    valid_options = ("raw", "row", "bitwise", "extfile")
409    for option in valid_options:
410        tto = ttx.Options([("-z", option)], 1)
411        assert tto.bitmapGlyphDataFormat == option
412
413
414def test_options_z_invalidoption():
415    with pytest.raises(getopt.GetoptError):
416        ttx.Options([("-z", "bogus")], 1)
417
418
419def test_options_y_validvalue():
420    tto = ttx.Options([("-y", "1")], 1)
421    assert tto.fontNumber == 1
422
423
424def test_options_y_invalidvalue():
425    with pytest.raises(ValueError):
426        ttx.Options([("-y", "A")], 1)
427
428
429def test_options_m():
430    tto = ttx.Options([("-m", "testfont.ttf")], 1)
431    assert tto.mergeFile == "testfont.ttf"
432
433
434def test_options_b():
435    tto = ttx.Options([("-b", "")], 1)
436    assert tto.recalcBBoxes is False
437
438
439def test_options_a():
440    tto = ttx.Options([("-a", "")], 1)
441    assert tto.allowVID is True
442
443
444def test_options_e():
445    tto = ttx.Options([("-e", "")], 1)
446    assert tto.ignoreDecompileErrors is False
447
448
449def test_options_unicodedata():
450    tto = ttx.Options([("--unicodedata", "UnicodeData.txt")], 1)
451    assert tto.unicodedata == "UnicodeData.txt"
452
453
454def test_options_newline_lf():
455    tto = ttx.Options([("--newline", "LF")], 1)
456    assert tto.newlinestr == "\n"
457
458
459def test_options_newline_cr():
460    tto = ttx.Options([("--newline", "CR")], 1)
461    assert tto.newlinestr == "\r"
462
463
464def test_options_newline_crlf():
465    tto = ttx.Options([("--newline", "CRLF")], 1)
466    assert tto.newlinestr == "\r\n"
467
468
469def test_options_newline_invalid():
470    with pytest.raises(getopt.GetoptError):
471        ttx.Options([("--newline", "BOGUS")], 1)
472
473
474def test_options_recalc_timestamp():
475    tto = ttx.Options([("--recalc-timestamp", "")], 1)
476    assert tto.recalcTimestamp is True
477
478
479def test_options_recalc_timestamp():
480    tto = ttx.Options([("--no-recalc-timestamp", "")], 1)
481    assert tto.recalcTimestamp is False
482
483
484def test_options_flavor():
485    tto = ttx.Options([("--flavor", "woff")], 1)
486    assert tto.flavor == "woff"
487
488
489def test_options_with_zopfli():
490    tto = ttx.Options([("--with-zopfli", ""), ("--flavor", "woff")], 1)
491    assert tto.useZopfli is True
492
493
494def test_options_with_zopfli_fails_without_woff_flavor():
495    with pytest.raises(getopt.GetoptError):
496        ttx.Options([("--with-zopfli", "")], 1)
497
498
499def test_options_quiet_and_verbose_shouldfail():
500    with pytest.raises(getopt.GetoptError):
501        ttx.Options([("-q", ""), ("-v", "")], 1)
502
503
504def test_options_mergefile_and_flavor_shouldfail():
505    with pytest.raises(getopt.GetoptError):
506        ttx.Options([("-m", "testfont.ttf"), ("--flavor", "woff")], 1)
507
508
509def test_options_onlytables_and_skiptables_shouldfail():
510    with pytest.raises(getopt.GetoptError):
511        ttx.Options([("-t", "CFF"), ("-x", "CFF2")], 1)
512
513
514def test_options_mergefile_and_multiplefiles_shouldfail():
515    with pytest.raises(getopt.GetoptError):
516        ttx.Options([("-m", "testfont.ttf")], 2)
517
518
519def test_options_woff2_and_zopfli_shouldfail():
520    with pytest.raises(getopt.GetoptError):
521        ttx.Options([("--with-zopfli", ""), ("--flavor", "woff2")], 1)
522
523
524# ----------------------------
525# ttx.ttCompile function tests
526# ----------------------------
527
528
529def test_ttcompile_otf_compile_default(tmpdir):
530    inttx = os.path.join("Tests", "ttx", "data", "TestOTF.ttx")
531    # outotf = os.path.join(str(tmpdir), "TestOTF.otf")
532    outotf = tmpdir.join("TestOTF.ttx")
533    default_options = ttx.Options([], 1)
534    ttx.ttCompile(inttx, str(outotf), default_options)
535    # confirm that font was built
536    assert outotf.check(file=True)
537    # confirm that it is valid OTF file, can instantiate a TTFont, has expected OpenType tables
538    ttf = TTFont(str(outotf))
539    expected_tables = (
540        "head",
541        "hhea",
542        "maxp",
543        "OS/2",
544        "name",
545        "cmap",
546        "post",
547        "CFF ",
548        "hmtx",
549        "DSIG",
550    )
551    for table in expected_tables:
552        assert table in ttf
553
554
555def test_ttcompile_otf_to_woff_without_zopfli(tmpdir):
556    inttx = os.path.join("Tests", "ttx", "data", "TestOTF.ttx")
557    outwoff = tmpdir.join("TestOTF.woff")
558    options = ttx.Options([], 1)
559    options.flavor = "woff"
560    ttx.ttCompile(inttx, str(outwoff), options)
561    # confirm that font was built
562    assert outwoff.check(file=True)
563    # confirm that it is valid TTF file, can instantiate a TTFont, has expected OpenType tables
564    ttf = TTFont(str(outwoff))
565    expected_tables = (
566        "head",
567        "hhea",
568        "maxp",
569        "OS/2",
570        "name",
571        "cmap",
572        "post",
573        "CFF ",
574        "hmtx",
575        "DSIG",
576    )
577    for table in expected_tables:
578        assert table in ttf
579
580
581@pytest.mark.skipif(zopfli is None, reason="zopfli not installed")
582def test_ttcompile_otf_to_woff_with_zopfli(tmpdir):
583    inttx = os.path.join("Tests", "ttx", "data", "TestOTF.ttx")
584    outwoff = tmpdir.join("TestOTF.woff")
585    options = ttx.Options([], 1)
586    options.flavor = "woff"
587    options.useZopfli = True
588    ttx.ttCompile(inttx, str(outwoff), options)
589    # confirm that font was built
590    assert outwoff.check(file=True)
591    # confirm that it is valid TTF file, can instantiate a TTFont, has expected OpenType tables
592    ttf = TTFont(str(outwoff))
593    expected_tables = (
594        "head",
595        "hhea",
596        "maxp",
597        "OS/2",
598        "name",
599        "cmap",
600        "post",
601        "CFF ",
602        "hmtx",
603        "DSIG",
604    )
605    for table in expected_tables:
606        assert table in ttf
607
608
609@pytest.mark.skipif(brotli is None, reason="brotli not installed")
610def test_ttcompile_otf_to_woff2(tmpdir):
611    inttx = os.path.join("Tests", "ttx", "data", "TestOTF.ttx")
612    outwoff2 = tmpdir.join("TestTTF.woff2")
613    options = ttx.Options([], 1)
614    options.flavor = "woff2"
615    ttx.ttCompile(inttx, str(outwoff2), options)
616    # confirm that font was built
617    assert outwoff2.check(file=True)
618    # confirm that it is valid TTF file, can instantiate a TTFont, has expected OpenType tables
619    ttf = TTFont(str(outwoff2))
620    # DSIG should not be included from original ttx as per woff2 spec (https://dev.w3.org/webfonts/WOFF2/spec/)
621    assert "DSIG" not in ttf
622    expected_tables = (
623        "head",
624        "hhea",
625        "maxp",
626        "OS/2",
627        "name",
628        "cmap",
629        "post",
630        "CFF ",
631        "hmtx",
632    )
633    for table in expected_tables:
634        assert table in ttf
635
636
637def test_ttcompile_ttf_compile_default(tmpdir):
638    inttx = os.path.join("Tests", "ttx", "data", "TestTTF.ttx")
639    outttf = tmpdir.join("TestTTF.ttf")
640    default_options = ttx.Options([], 1)
641    ttx.ttCompile(inttx, str(outttf), default_options)
642    # confirm that font was built
643    assert outttf.check(file=True)
644    # confirm that it is valid TTF file, can instantiate a TTFont, has expected OpenType tables
645    ttf = TTFont(str(outttf))
646    expected_tables = (
647        "head",
648        "hhea",
649        "maxp",
650        "OS/2",
651        "name",
652        "cmap",
653        "hmtx",
654        "fpgm",
655        "prep",
656        "cvt ",
657        "loca",
658        "glyf",
659        "post",
660        "gasp",
661        "DSIG",
662    )
663    for table in expected_tables:
664        assert table in ttf
665
666
667def test_ttcompile_ttf_to_woff_without_zopfli(tmpdir):
668    inttx = os.path.join("Tests", "ttx", "data", "TestTTF.ttx")
669    outwoff = tmpdir.join("TestTTF.woff")
670    options = ttx.Options([], 1)
671    options.flavor = "woff"
672    ttx.ttCompile(inttx, str(outwoff), options)
673    # confirm that font was built
674    assert outwoff.check(file=True)
675    # confirm that it is valid TTF file, can instantiate a TTFont, has expected OpenType tables
676    ttf = TTFont(str(outwoff))
677    expected_tables = (
678        "head",
679        "hhea",
680        "maxp",
681        "OS/2",
682        "name",
683        "cmap",
684        "hmtx",
685        "fpgm",
686        "prep",
687        "cvt ",
688        "loca",
689        "glyf",
690        "post",
691        "gasp",
692        "DSIG",
693    )
694    for table in expected_tables:
695        assert table in ttf
696
697
698@pytest.mark.skipif(zopfli is None, reason="zopfli not installed")
699def test_ttcompile_ttf_to_woff_with_zopfli(tmpdir):
700    inttx = os.path.join("Tests", "ttx", "data", "TestTTF.ttx")
701    outwoff = tmpdir.join("TestTTF.woff")
702    options = ttx.Options([], 1)
703    options.flavor = "woff"
704    options.useZopfli = True
705    ttx.ttCompile(inttx, str(outwoff), options)
706    # confirm that font was built
707    assert outwoff.check(file=True)
708    # confirm that it is valid TTF file, can instantiate a TTFont, has expected OpenType tables
709    ttf = TTFont(str(outwoff))
710    expected_tables = (
711        "head",
712        "hhea",
713        "maxp",
714        "OS/2",
715        "name",
716        "cmap",
717        "hmtx",
718        "fpgm",
719        "prep",
720        "cvt ",
721        "loca",
722        "glyf",
723        "post",
724        "gasp",
725        "DSIG",
726    )
727    for table in expected_tables:
728        assert table in ttf
729
730
731@pytest.mark.skipif(brotli is None, reason="brotli not installed")
732def test_ttcompile_ttf_to_woff2(tmpdir):
733    inttx = os.path.join("Tests", "ttx", "data", "TestTTF.ttx")
734    outwoff2 = tmpdir.join("TestTTF.woff2")
735    options = ttx.Options([], 1)
736    options.flavor = "woff2"
737    ttx.ttCompile(inttx, str(outwoff2), options)
738    # confirm that font was built
739    assert outwoff2.check(file=True)
740    # confirm that it is valid TTF file, can instantiate a TTFont, has expected OpenType tables
741    ttf = TTFont(str(outwoff2))
742    # DSIG should not be included from original ttx as per woff2 spec (https://dev.w3.org/webfonts/WOFF2/spec/)
743    assert "DSIG" not in ttf
744    expected_tables = (
745        "head",
746        "hhea",
747        "maxp",
748        "OS/2",
749        "name",
750        "cmap",
751        "hmtx",
752        "fpgm",
753        "prep",
754        "cvt ",
755        "loca",
756        "glyf",
757        "post",
758        "gasp",
759    )
760    for table in expected_tables:
761        assert table in ttf
762
763
764@pytest.mark.parametrize(
765    "inpath, outpath1, outpath2",
766    [
767        ("TestTTF.ttx", "TestTTF1.ttf", "TestTTF2.ttf"),
768        ("TestOTF.ttx", "TestOTF1.otf", "TestOTF2.otf"),
769    ],
770)
771def test_ttcompile_timestamp_calcs(inpath, outpath1, outpath2, tmpdir):
772    inttx = os.path.join("Tests", "ttx", "data", inpath)
773    outttf1 = tmpdir.join(outpath1)
774    outttf2 = tmpdir.join(outpath2)
775    options = ttx.Options([], 1)
776    # build with default options = do not recalculate timestamp
777    ttx.ttCompile(inttx, str(outttf1), options)
778    # confirm that font was built
779    assert outttf1.check(file=True)
780    # confirm that timestamp is same as modified time on ttx file
781    mtime = os.path.getmtime(inttx)
782    epochtime = timestampSinceEpoch(mtime)
783    ttf = TTFont(str(outttf1))
784    assert ttf["head"].modified == epochtime
785
786    # reset options to recalculate the timestamp and compile new font
787    options.recalcTimestamp = True
788    ttx.ttCompile(inttx, str(outttf2), options)
789    # confirm that font was built
790    assert outttf2.check(file=True)
791    # confirm that timestamp is more recent than modified time on ttx file
792    mtime = os.path.getmtime(inttx)
793    epochtime = timestampSinceEpoch(mtime)
794    ttf = TTFont(str(outttf2))
795    assert ttf["head"].modified > epochtime
796
797    # --no-recalc-timestamp will keep original timestamp
798    options.recalcTimestamp = False
799    ttx.ttCompile(inttx, str(outttf2), options)
800    assert outttf2.check(file=True)
801    inttf = TTFont()
802    inttf.importXML(inttx)
803    assert inttf["head"].modified == TTFont(str(outttf2))["head"].modified
804
805
806# -------------------------
807# ttx.ttList function tests
808# -------------------------
809
810
811def test_ttlist_ttf(capsys, tmpdir):
812    inpath = os.path.join("Tests", "ttx", "data", "TestTTF.ttf")
813    fakeoutpath = tmpdir.join("TestTTF.ttx")
814    options = ttx.Options([], 1)
815    options.listTables = True
816    ttx.ttList(inpath, str(fakeoutpath), options)
817    out, err = capsys.readouterr()
818    expected_tables = (
819        "head",
820        "hhea",
821        "maxp",
822        "OS/2",
823        "name",
824        "cmap",
825        "hmtx",
826        "fpgm",
827        "prep",
828        "cvt ",
829        "loca",
830        "glyf",
831        "post",
832        "gasp",
833        "DSIG",
834    )
835    # confirm that expected tables are printed to stdout
836    for table in expected_tables:
837        assert table in out
838    # test for one of the expected tag/checksum/length/offset strings
839    assert "OS/2  0x67230FF8        96       376" in out
840
841
842def test_ttlist_otf(capsys, tmpdir):
843    inpath = os.path.join("Tests", "ttx", "data", "TestOTF.otf")
844    fakeoutpath = tmpdir.join("TestOTF.ttx")
845    options = ttx.Options([], 1)
846    options.listTables = True
847    ttx.ttList(inpath, str(fakeoutpath), options)
848    out, err = capsys.readouterr()
849    expected_tables = (
850        "head",
851        "hhea",
852        "maxp",
853        "OS/2",
854        "name",
855        "cmap",
856        "post",
857        "CFF ",
858        "hmtx",
859        "DSIG",
860    )
861    # confirm that expected tables are printed to stdout
862    for table in expected_tables:
863        assert table in out
864    # test for one of the expected tag/checksum/length/offset strings
865    assert "OS/2  0x67230FF8        96       272" in out
866
867
868def test_ttlist_woff(capsys, tmpdir):
869    inpath = os.path.join("Tests", "ttx", "data", "TestWOFF.woff")
870    fakeoutpath = tmpdir.join("TestWOFF.ttx")
871    options = ttx.Options([], 1)
872    options.listTables = True
873    options.flavor = "woff"
874    ttx.ttList(inpath, str(fakeoutpath), options)
875    out, err = capsys.readouterr()
876    expected_tables = (
877        "head",
878        "hhea",
879        "maxp",
880        "OS/2",
881        "name",
882        "cmap",
883        "post",
884        "CFF ",
885        "hmtx",
886        "DSIG",
887    )
888    # confirm that expected tables are printed to stdout
889    for table in expected_tables:
890        assert table in out
891    # test for one of the expected tag/checksum/length/offset strings
892    assert "OS/2  0x67230FF8        84       340" in out
893
894
895@pytest.mark.skipif(brotli is None, reason="brotli not installed")
896def test_ttlist_woff2(capsys, tmpdir):
897    inpath = os.path.join("Tests", "ttx", "data", "TestWOFF2.woff2")
898    fakeoutpath = tmpdir.join("TestWOFF2.ttx")
899    options = ttx.Options([], 1)
900    options.listTables = True
901    options.flavor = "woff2"
902    ttx.ttList(inpath, str(fakeoutpath), options)
903    out, err = capsys.readouterr()
904    expected_tables = (
905        "head",
906        "hhea",
907        "maxp",
908        "OS/2",
909        "name",
910        "cmap",
911        "hmtx",
912        "fpgm",
913        "prep",
914        "cvt ",
915        "loca",
916        "glyf",
917        "post",
918        "gasp",
919    )
920    # confirm that expected tables are printed to stdout
921    for table in expected_tables:
922        assert table in out
923    # test for one of the expected tag/checksum/length/offset strings
924    assert "OS/2  0x67230FF8        96         0" in out
925
926
927# -------------------
928# main function tests
929# -------------------
930
931
932def test_main_default_ttf_dump_to_ttx(tmpdir):
933    inpath = os.path.join("Tests", "ttx", "data", "TestTTF.ttf")
934    outpath = tmpdir.join("TestTTF.ttx")
935    args = ["-o", str(outpath), inpath]
936    ttx.main(args)
937    assert outpath.check(file=True)
938
939
940def test_main_default_ttx_compile_to_ttf(tmpdir):
941    inpath = os.path.join("Tests", "ttx", "data", "TestTTF.ttx")
942    outpath = tmpdir.join("TestTTF.ttf")
943    args = ["-o", str(outpath), inpath]
944    ttx.main(args)
945    assert outpath.check(file=True)
946
947
948def test_main_getopterror_missing_directory():
949    with pytest.raises(SystemExit):
950        with pytest.raises(getopt.GetoptError):
951            inpath = os.path.join("Tests", "ttx", "data", "TestTTF.ttf")
952            args = ["-d", "bogusdir", inpath]
953            ttx.main(args)
954
955
956def test_main_keyboard_interrupt(tmpdir, monkeypatch, capsys):
957    with pytest.raises(SystemExit):
958        inpath = os.path.join("Tests", "ttx", "data", "TestTTF.ttx")
959        outpath = tmpdir.join("TestTTF.ttf")
960        args = ["-o", str(outpath), inpath]
961        monkeypatch.setattr(
962            ttx, "process", (lambda x, y: raise_exception(KeyboardInterrupt))
963        )
964        ttx.main(args)
965
966    out, err = capsys.readouterr()
967    assert "(Cancelled.)" in err
968
969
970@pytest.mark.skipif(
971    sys.platform == "win32",
972    reason="waitForKeyPress function causes test to hang on Windows platform",
973)
974def test_main_system_exit(tmpdir, monkeypatch):
975    with pytest.raises(SystemExit):
976        inpath = os.path.join("Tests", "ttx", "data", "TestTTF.ttx")
977        outpath = tmpdir.join("TestTTF.ttf")
978        args = ["-o", str(outpath), inpath]
979        monkeypatch.setattr(
980            ttx, "process", (lambda x, y: raise_exception(SystemExit))
981        )
982        ttx.main(args)
983
984
985def test_main_ttlib_error(tmpdir, monkeypatch, capsys):
986    with pytest.raises(SystemExit):
987        inpath = os.path.join("Tests", "ttx", "data", "TestTTF.ttx")
988        outpath = tmpdir.join("TestTTF.ttf")
989        args = ["-o", str(outpath), inpath]
990        monkeypatch.setattr(
991            ttx,
992            "process",
993            (lambda x, y: raise_exception(TTLibError("Test error"))),
994        )
995        ttx.main(args)
996
997    out, err = capsys.readouterr()
998    assert "Test error" in err
999
1000
1001@pytest.mark.skipif(
1002    sys.platform == "win32",
1003    reason="waitForKeyPress function causes test to hang on Windows platform",
1004)
1005def test_main_base_exception(tmpdir, monkeypatch, capsys):
1006    with pytest.raises(SystemExit):
1007        inpath = os.path.join("Tests", "ttx", "data", "TestTTF.ttx")
1008        outpath = tmpdir.join("TestTTF.ttf")
1009        args = ["-o", str(outpath), inpath]
1010        monkeypatch.setattr(
1011            ttx,
1012            "process",
1013            (lambda x, y: raise_exception(Exception("Test error"))),
1014        )
1015        ttx.main(args)
1016
1017    out, err = capsys.readouterr()
1018    assert "Unhandled exception has occurred" in err
1019
1020
1021# ---------------------------
1022# support functions for tests
1023# ---------------------------
1024
1025
1026def raise_exception(exception):
1027    raise exception
1028