xref: /aosp_15_r20/external/fonttools/Tests/ttx/ttx_test.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
1from fontTools.misc.testTools import parseXML
2from fontTools.misc.timeTools import timestampSinceEpoch
3from fontTools.ttLib import TTFont, TTLibError
4from fontTools.ttLib.tables.DefaultTable import DefaultTable
5from fontTools import ttx
6import base64
7import getopt
8import logging
9import os
10import shutil
11import subprocess
12import sys
13import tempfile
14import unittest
15from pathlib import Path
16
17import pytest
18
19try:
20    import zopfli
21except ImportError:
22    zopfli = None
23try:
24    try:
25        import brotlicffi as brotli
26    except ImportError:
27        import brotli
28except ImportError:
29    brotli = None
30
31
32class TTXTest(unittest.TestCase):
33    def __init__(self, methodName):
34        unittest.TestCase.__init__(self, methodName)
35        # Python 3 renamed assertRaisesRegexp to assertRaisesRegex,
36        # and fires deprecation warnings if a program uses the old name.
37        if not hasattr(self, "assertRaisesRegex"):
38            self.assertRaisesRegex = self.assertRaisesRegexp
39
40    def setUp(self):
41        self.tempdir = None
42        self.num_tempfiles = 0
43
44    def tearDown(self):
45        if self.tempdir:
46            shutil.rmtree(self.tempdir)
47
48    @staticmethod
49    def getpath(testfile):
50        path, _ = os.path.split(__file__)
51        return os.path.join(path, "data", testfile)
52
53    def temp_dir(self):
54        if not self.tempdir:
55            self.tempdir = tempfile.mkdtemp()
56
57    def temp_font(self, font_path, file_name):
58        self.temp_dir()
59        temppath = os.path.join(self.tempdir, file_name)
60        shutil.copy2(font_path, temppath)
61        return temppath
62
63    @staticmethod
64    def read_file(file_path):
65        with open(file_path, "r", encoding="utf-8") as f:
66            return f.readlines()
67
68    # -----
69    # Tests
70    # -----
71
72    def test_parseOptions_no_args(self):
73        with self.assertRaises(getopt.GetoptError) as cm:
74            ttx.parseOptions([])
75        self.assertTrue("Must specify at least one input file" in str(cm.exception))
76
77    def test_parseOptions_invalid_path(self):
78        file_path = "invalid_font_path"
79        with self.assertRaises(getopt.GetoptError) as cm:
80            ttx.parseOptions([file_path])
81        self.assertTrue('File not found: "%s"' % file_path in str(cm.exception))
82
83    def test_parseOptions_font2ttx_1st_time(self):
84        file_name = "TestOTF.otf"
85        font_path = self.getpath(file_name)
86        temp_path = self.temp_font(font_path, file_name)
87        jobs, _ = ttx.parseOptions([temp_path])
88        self.assertEqual(jobs[0][0].__name__, "ttDump")
89        self.assertEqual(
90            jobs[0][1:],
91            (
92                os.path.join(self.tempdir, file_name),
93                os.path.join(self.tempdir, file_name.split(".")[0] + ".ttx"),
94            ),
95        )
96
97    def test_parseOptions_font2ttx_2nd_time(self):
98        file_name = "TestTTF.ttf"
99        font_path = self.getpath(file_name)
100        temp_path = self.temp_font(font_path, file_name)
101        _, _ = ttx.parseOptions([temp_path])  # this is NOT a mistake
102        jobs, _ = ttx.parseOptions([temp_path])
103        self.assertEqual(jobs[0][0].__name__, "ttDump")
104        self.assertEqual(
105            jobs[0][1:],
106            (
107                os.path.join(self.tempdir, file_name),
108                os.path.join(self.tempdir, file_name.split(".")[0] + "#1.ttx"),
109            ),
110        )
111
112    def test_parseOptions_ttx2font_1st_time(self):
113        file_name = "TestTTF.ttx"
114        font_path = self.getpath(file_name)
115        temp_path = self.temp_font(font_path, file_name)
116        jobs, _ = ttx.parseOptions([temp_path])
117        self.assertEqual(jobs[0][0].__name__, "ttCompile")
118        self.assertEqual(
119            jobs[0][1:],
120            (
121                os.path.join(self.tempdir, file_name),
122                os.path.join(self.tempdir, file_name.split(".")[0] + ".ttf"),
123            ),
124        )
125
126    def test_parseOptions_ttx2font_2nd_time(self):
127        file_name = "TestOTF.ttx"
128        font_path = self.getpath(file_name)
129        temp_path = self.temp_font(font_path, file_name)
130        _, _ = ttx.parseOptions([temp_path])  # this is NOT a mistake
131        jobs, _ = ttx.parseOptions([temp_path])
132        self.assertEqual(jobs[0][0].__name__, "ttCompile")
133        self.assertEqual(
134            jobs[0][1:],
135            (
136                os.path.join(self.tempdir, file_name),
137                os.path.join(self.tempdir, file_name.split(".")[0] + "#1.otf"),
138            ),
139        )
140
141    def test_parseOptions_multiple_fonts(self):
142        file_names = ["TestOTF.otf", "TestTTF.ttf"]
143        font_paths = [self.getpath(file_name) for file_name in file_names]
144        temp_paths = [
145            self.temp_font(font_path, file_name)
146            for font_path, file_name in zip(font_paths, file_names)
147        ]
148        jobs, _ = ttx.parseOptions(temp_paths)
149        for i in range(len(jobs)):
150            self.assertEqual(jobs[i][0].__name__, "ttDump")
151            self.assertEqual(
152                jobs[i][1:],
153                (
154                    os.path.join(self.tempdir, file_names[i]),
155                    os.path.join(self.tempdir, file_names[i].split(".")[0] + ".ttx"),
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_e():
440    tto = ttx.Options([("-e", "")], 1)
441    assert tto.ignoreDecompileErrors is False
442
443
444def test_options_unicodedata():
445    tto = ttx.Options([("--unicodedata", "UnicodeData.txt")], 1)
446    assert tto.unicodedata == "UnicodeData.txt"
447
448
449def test_options_newline_lf():
450    tto = ttx.Options([("--newline", "LF")], 1)
451    assert tto.newlinestr == "\n"
452
453
454def test_options_newline_cr():
455    tto = ttx.Options([("--newline", "CR")], 1)
456    assert tto.newlinestr == "\r"
457
458
459def test_options_newline_crlf():
460    tto = ttx.Options([("--newline", "CRLF")], 1)
461    assert tto.newlinestr == "\r\n"
462
463
464def test_options_newline_invalid():
465    with pytest.raises(getopt.GetoptError):
466        ttx.Options([("--newline", "BOGUS")], 1)
467
468
469def test_options_recalc_timestamp():
470    tto = ttx.Options([("--recalc-timestamp", "")], 1)
471    assert tto.recalcTimestamp is True
472
473
474def test_options_recalc_timestamp():
475    tto = ttx.Options([("--no-recalc-timestamp", "")], 1)
476    assert tto.recalcTimestamp is False
477
478
479def test_options_flavor():
480    tto = ttx.Options([("--flavor", "woff")], 1)
481    assert tto.flavor == "woff"
482
483
484def test_options_with_zopfli():
485    tto = ttx.Options([("--with-zopfli", ""), ("--flavor", "woff")], 1)
486    assert tto.useZopfli is True
487
488
489def test_options_with_zopfli_fails_without_woff_flavor():
490    with pytest.raises(getopt.GetoptError):
491        ttx.Options([("--with-zopfli", "")], 1)
492
493
494def test_options_quiet_and_verbose_shouldfail():
495    with pytest.raises(getopt.GetoptError):
496        ttx.Options([("-q", ""), ("-v", "")], 1)
497
498
499def test_options_mergefile_and_flavor_shouldfail():
500    with pytest.raises(getopt.GetoptError):
501        ttx.Options([("-m", "testfont.ttf"), ("--flavor", "woff")], 1)
502
503
504def test_options_onlytables_and_skiptables_shouldfail():
505    with pytest.raises(getopt.GetoptError):
506        ttx.Options([("-t", "CFF"), ("-x", "CFF2")], 1)
507
508
509def test_options_mergefile_and_multiplefiles_shouldfail():
510    with pytest.raises(getopt.GetoptError):
511        ttx.Options([("-m", "testfont.ttf")], 2)
512
513
514def test_options_woff2_and_zopfli_shouldfail():
515    with pytest.raises(getopt.GetoptError):
516        ttx.Options([("--with-zopfli", ""), ("--flavor", "woff2")], 1)
517
518
519# ----------------------------
520# ttx.ttCompile function tests
521# ----------------------------
522
523
524def test_ttcompile_otf_compile_default(tmpdir):
525    inttx = os.path.join("Tests", "ttx", "data", "TestOTF.ttx")
526    # outotf = os.path.join(str(tmpdir), "TestOTF.otf")
527    outotf = tmpdir.join("TestOTF.ttx")
528    default_options = ttx.Options([], 1)
529    ttx.ttCompile(inttx, str(outotf), default_options)
530    # confirm that font was built
531    assert outotf.check(file=True)
532    # confirm that it is valid OTF file, can instantiate a TTFont, has expected OpenType tables
533    ttf = TTFont(str(outotf))
534    expected_tables = (
535        "head",
536        "hhea",
537        "maxp",
538        "OS/2",
539        "name",
540        "cmap",
541        "post",
542        "CFF ",
543        "hmtx",
544        "DSIG",
545    )
546    for table in expected_tables:
547        assert table in ttf
548
549
550def test_ttcompile_otf_to_woff_without_zopfli(tmpdir):
551    inttx = os.path.join("Tests", "ttx", "data", "TestOTF.ttx")
552    outwoff = tmpdir.join("TestOTF.woff")
553    options = ttx.Options([], 1)
554    options.flavor = "woff"
555    ttx.ttCompile(inttx, str(outwoff), options)
556    # confirm that font was built
557    assert outwoff.check(file=True)
558    # confirm that it is valid TTF file, can instantiate a TTFont, has expected OpenType tables
559    ttf = TTFont(str(outwoff))
560    expected_tables = (
561        "head",
562        "hhea",
563        "maxp",
564        "OS/2",
565        "name",
566        "cmap",
567        "post",
568        "CFF ",
569        "hmtx",
570        "DSIG",
571    )
572    for table in expected_tables:
573        assert table in ttf
574
575
576@pytest.mark.skipif(zopfli is None, reason="zopfli not installed")
577def test_ttcompile_otf_to_woff_with_zopfli(tmpdir):
578    inttx = os.path.join("Tests", "ttx", "data", "TestOTF.ttx")
579    outwoff = tmpdir.join("TestOTF.woff")
580    options = ttx.Options([], 1)
581    options.flavor = "woff"
582    options.useZopfli = True
583    ttx.ttCompile(inttx, str(outwoff), options)
584    # confirm that font was built
585    assert outwoff.check(file=True)
586    # confirm that it is valid TTF file, can instantiate a TTFont, has expected OpenType tables
587    ttf = TTFont(str(outwoff))
588    expected_tables = (
589        "head",
590        "hhea",
591        "maxp",
592        "OS/2",
593        "name",
594        "cmap",
595        "post",
596        "CFF ",
597        "hmtx",
598        "DSIG",
599    )
600    for table in expected_tables:
601        assert table in ttf
602
603
604@pytest.mark.skipif(brotli is None, reason="brotli not installed")
605def test_ttcompile_otf_to_woff2(tmpdir):
606    inttx = os.path.join("Tests", "ttx", "data", "TestOTF.ttx")
607    outwoff2 = tmpdir.join("TestTTF.woff2")
608    options = ttx.Options([], 1)
609    options.flavor = "woff2"
610    ttx.ttCompile(inttx, str(outwoff2), options)
611    # confirm that font was built
612    assert outwoff2.check(file=True)
613    # confirm that it is valid TTF file, can instantiate a TTFont, has expected OpenType tables
614    ttf = TTFont(str(outwoff2))
615    # DSIG should not be included from original ttx as per woff2 spec (https://dev.w3.org/webfonts/WOFF2/spec/)
616    assert "DSIG" not in ttf
617    expected_tables = (
618        "head",
619        "hhea",
620        "maxp",
621        "OS/2",
622        "name",
623        "cmap",
624        "post",
625        "CFF ",
626        "hmtx",
627    )
628    for table in expected_tables:
629        assert table in ttf
630
631
632def test_ttcompile_ttf_compile_default(tmpdir):
633    inttx = os.path.join("Tests", "ttx", "data", "TestTTF.ttx")
634    outttf = tmpdir.join("TestTTF.ttf")
635    default_options = ttx.Options([], 1)
636    ttx.ttCompile(inttx, str(outttf), default_options)
637    # confirm that font was built
638    assert outttf.check(file=True)
639    # confirm that it is valid TTF file, can instantiate a TTFont, has expected OpenType tables
640    ttf = TTFont(str(outttf))
641    expected_tables = (
642        "head",
643        "hhea",
644        "maxp",
645        "OS/2",
646        "name",
647        "cmap",
648        "hmtx",
649        "fpgm",
650        "prep",
651        "cvt ",
652        "loca",
653        "glyf",
654        "post",
655        "gasp",
656        "DSIG",
657    )
658    for table in expected_tables:
659        assert table in ttf
660
661
662def test_ttcompile_ttf_to_woff_without_zopfli(tmpdir):
663    inttx = os.path.join("Tests", "ttx", "data", "TestTTF.ttx")
664    outwoff = tmpdir.join("TestTTF.woff")
665    options = ttx.Options([], 1)
666    options.flavor = "woff"
667    ttx.ttCompile(inttx, str(outwoff), options)
668    # confirm that font was built
669    assert outwoff.check(file=True)
670    # confirm that it is valid TTF file, can instantiate a TTFont, has expected OpenType tables
671    ttf = TTFont(str(outwoff))
672    expected_tables = (
673        "head",
674        "hhea",
675        "maxp",
676        "OS/2",
677        "name",
678        "cmap",
679        "hmtx",
680        "fpgm",
681        "prep",
682        "cvt ",
683        "loca",
684        "glyf",
685        "post",
686        "gasp",
687        "DSIG",
688    )
689    for table in expected_tables:
690        assert table in ttf
691
692
693@pytest.mark.skipif(zopfli is None, reason="zopfli not installed")
694def test_ttcompile_ttf_to_woff_with_zopfli(tmpdir):
695    inttx = os.path.join("Tests", "ttx", "data", "TestTTF.ttx")
696    outwoff = tmpdir.join("TestTTF.woff")
697    options = ttx.Options([], 1)
698    options.flavor = "woff"
699    options.useZopfli = True
700    ttx.ttCompile(inttx, str(outwoff), options)
701    # confirm that font was built
702    assert outwoff.check(file=True)
703    # confirm that it is valid TTF file, can instantiate a TTFont, has expected OpenType tables
704    ttf = TTFont(str(outwoff))
705    expected_tables = (
706        "head",
707        "hhea",
708        "maxp",
709        "OS/2",
710        "name",
711        "cmap",
712        "hmtx",
713        "fpgm",
714        "prep",
715        "cvt ",
716        "loca",
717        "glyf",
718        "post",
719        "gasp",
720        "DSIG",
721    )
722    for table in expected_tables:
723        assert table in ttf
724
725
726@pytest.mark.skipif(brotli is None, reason="brotli not installed")
727def test_ttcompile_ttf_to_woff2(tmpdir):
728    inttx = os.path.join("Tests", "ttx", "data", "TestTTF.ttx")
729    outwoff2 = tmpdir.join("TestTTF.woff2")
730    options = ttx.Options([], 1)
731    options.flavor = "woff2"
732    ttx.ttCompile(inttx, str(outwoff2), options)
733    # confirm that font was built
734    assert outwoff2.check(file=True)
735    # confirm that it is valid TTF file, can instantiate a TTFont, has expected OpenType tables
736    ttf = TTFont(str(outwoff2))
737    # DSIG should not be included from original ttx as per woff2 spec (https://dev.w3.org/webfonts/WOFF2/spec/)
738    assert "DSIG" not in ttf
739    expected_tables = (
740        "head",
741        "hhea",
742        "maxp",
743        "OS/2",
744        "name",
745        "cmap",
746        "hmtx",
747        "fpgm",
748        "prep",
749        "cvt ",
750        "loca",
751        "glyf",
752        "post",
753        "gasp",
754    )
755    for table in expected_tables:
756        assert table in ttf
757
758
759@pytest.mark.parametrize(
760    "inpath, outpath1, outpath2",
761    [
762        ("TestTTF.ttx", "TestTTF1.ttf", "TestTTF2.ttf"),
763        ("TestOTF.ttx", "TestOTF1.otf", "TestOTF2.otf"),
764    ],
765)
766def test_ttcompile_timestamp_calcs(inpath, outpath1, outpath2, tmpdir):
767    inttx = os.path.join("Tests", "ttx", "data", inpath)
768    outttf1 = tmpdir.join(outpath1)
769    outttf2 = tmpdir.join(outpath2)
770    options = ttx.Options([], 1)
771    # build with default options = do not recalculate timestamp
772    ttx.ttCompile(inttx, str(outttf1), options)
773    # confirm that font was built
774    assert outttf1.check(file=True)
775    # confirm that timestamp is same as modified time on ttx file
776    mtime = os.path.getmtime(inttx)
777    epochtime = timestampSinceEpoch(mtime)
778    ttf = TTFont(str(outttf1))
779    assert ttf["head"].modified == epochtime
780
781    # reset options to recalculate the timestamp and compile new font
782    options.recalcTimestamp = True
783    ttx.ttCompile(inttx, str(outttf2), options)
784    # confirm that font was built
785    assert outttf2.check(file=True)
786    # confirm that timestamp is more recent than modified time on ttx file
787    mtime = os.path.getmtime(inttx)
788    epochtime = timestampSinceEpoch(mtime)
789    ttf = TTFont(str(outttf2))
790    assert ttf["head"].modified > epochtime
791
792    # --no-recalc-timestamp will keep original timestamp
793    options.recalcTimestamp = False
794    ttx.ttCompile(inttx, str(outttf2), options)
795    assert outttf2.check(file=True)
796    inttf = TTFont()
797    inttf.importXML(inttx)
798    assert inttf["head"].modified == TTFont(str(outttf2))["head"].modified
799
800
801# -------------------------
802# ttx.ttList function tests
803# -------------------------
804
805
806def test_ttlist_ttf(capsys, tmpdir):
807    inpath = os.path.join("Tests", "ttx", "data", "TestTTF.ttf")
808    fakeoutpath = tmpdir.join("TestTTF.ttx")
809    options = ttx.Options([], 1)
810    options.listTables = True
811    ttx.ttList(inpath, str(fakeoutpath), options)
812    out, err = capsys.readouterr()
813    expected_tables = (
814        "head",
815        "hhea",
816        "maxp",
817        "OS/2",
818        "name",
819        "cmap",
820        "hmtx",
821        "fpgm",
822        "prep",
823        "cvt ",
824        "loca",
825        "glyf",
826        "post",
827        "gasp",
828        "DSIG",
829    )
830    # confirm that expected tables are printed to stdout
831    for table in expected_tables:
832        assert table in out
833    # test for one of the expected tag/checksum/length/offset strings
834    assert "OS/2  0x67230FF8        96       376" in out
835
836
837def test_ttlist_otf(capsys, tmpdir):
838    inpath = os.path.join("Tests", "ttx", "data", "TestOTF.otf")
839    fakeoutpath = tmpdir.join("TestOTF.ttx")
840    options = ttx.Options([], 1)
841    options.listTables = True
842    ttx.ttList(inpath, str(fakeoutpath), options)
843    out, err = capsys.readouterr()
844    expected_tables = (
845        "head",
846        "hhea",
847        "maxp",
848        "OS/2",
849        "name",
850        "cmap",
851        "post",
852        "CFF ",
853        "hmtx",
854        "DSIG",
855    )
856    # confirm that expected tables are printed to stdout
857    for table in expected_tables:
858        assert table in out
859    # test for one of the expected tag/checksum/length/offset strings
860    assert "OS/2  0x67230FF8        96       272" in out
861
862
863def test_ttlist_woff(capsys, tmpdir):
864    inpath = os.path.join("Tests", "ttx", "data", "TestWOFF.woff")
865    fakeoutpath = tmpdir.join("TestWOFF.ttx")
866    options = ttx.Options([], 1)
867    options.listTables = True
868    options.flavor = "woff"
869    ttx.ttList(inpath, str(fakeoutpath), options)
870    out, err = capsys.readouterr()
871    expected_tables = (
872        "head",
873        "hhea",
874        "maxp",
875        "OS/2",
876        "name",
877        "cmap",
878        "post",
879        "CFF ",
880        "hmtx",
881        "DSIG",
882    )
883    # confirm that expected tables are printed to stdout
884    for table in expected_tables:
885        assert table in out
886    # test for one of the expected tag/checksum/length/offset strings
887    assert "OS/2  0x67230FF8        84       340" in out
888
889
890@pytest.mark.skipif(brotli is None, reason="brotli not installed")
891def test_ttlist_woff2(capsys, tmpdir):
892    inpath = os.path.join("Tests", "ttx", "data", "TestWOFF2.woff2")
893    fakeoutpath = tmpdir.join("TestWOFF2.ttx")
894    options = ttx.Options([], 1)
895    options.listTables = True
896    options.flavor = "woff2"
897    ttx.ttList(inpath, str(fakeoutpath), options)
898    out, err = capsys.readouterr()
899    expected_tables = (
900        "head",
901        "hhea",
902        "maxp",
903        "OS/2",
904        "name",
905        "cmap",
906        "hmtx",
907        "fpgm",
908        "prep",
909        "cvt ",
910        "loca",
911        "glyf",
912        "post",
913        "gasp",
914    )
915    # confirm that expected tables are printed to stdout
916    for table in expected_tables:
917        assert table in out
918    # test for one of the expected tag/checksum/length/offset strings
919    assert "OS/2  0x67230FF8        96         0" in out
920
921
922# -------------------
923# main function tests
924# -------------------
925
926
927def test_main_default_ttf_dump_to_ttx(tmpdir):
928    inpath = os.path.join("Tests", "ttx", "data", "TestTTF.ttf")
929    outpath = tmpdir.join("TestTTF.ttx")
930    args = ["-o", str(outpath), inpath]
931    ttx.main(args)
932    assert outpath.check(file=True)
933
934
935def test_main_default_ttx_compile_to_ttf(tmpdir):
936    inpath = os.path.join("Tests", "ttx", "data", "TestTTF.ttx")
937    outpath = tmpdir.join("TestTTF.ttf")
938    args = ["-o", str(outpath), inpath]
939    ttx.main(args)
940    assert outpath.check(file=True)
941
942
943def test_main_getopterror_missing_directory():
944    with pytest.raises(SystemExit):
945        with pytest.raises(getopt.GetoptError):
946            inpath = os.path.join("Tests", "ttx", "data", "TestTTF.ttf")
947            args = ["-d", "bogusdir", inpath]
948            ttx.main(args)
949
950
951def test_main_keyboard_interrupt(tmpdir, monkeypatch, caplog):
952    with pytest.raises(SystemExit):
953        inpath = os.path.join("Tests", "ttx", "data", "TestTTF.ttx")
954        outpath = tmpdir.join("TestTTF.ttf")
955        args = ["-o", str(outpath), inpath]
956        monkeypatch.setattr(
957            ttx, "process", (lambda x, y: raise_exception(KeyboardInterrupt))
958        )
959        ttx.main(args)
960
961    assert "(Cancelled.)" in caplog.text
962
963
964def test_main_system_exit(tmpdir, monkeypatch):
965    with pytest.raises(SystemExit):
966        inpath = os.path.join("Tests", "ttx", "data", "TestTTF.ttx")
967        outpath = tmpdir.join("TestTTF.ttf")
968        args = ["-o", str(outpath), inpath]
969        monkeypatch.setattr(ttx, "process", (lambda x, y: raise_exception(SystemExit)))
970        ttx.main(args)
971
972
973def test_main_ttlib_error(tmpdir, monkeypatch, caplog):
974    with pytest.raises(SystemExit):
975        inpath = os.path.join("Tests", "ttx", "data", "TestTTF.ttx")
976        outpath = tmpdir.join("TestTTF.ttf")
977        args = ["-o", str(outpath), inpath]
978        monkeypatch.setattr(
979            ttx,
980            "process",
981            (lambda x, y: raise_exception(TTLibError("Test error"))),
982        )
983        ttx.main(args)
984
985    assert "Test error" in caplog.text
986
987
988def test_main_base_exception(tmpdir, monkeypatch, caplog):
989    with pytest.raises(SystemExit):
990        inpath = os.path.join("Tests", "ttx", "data", "TestTTF.ttx")
991        outpath = tmpdir.join("TestTTF.ttf")
992        args = ["-o", str(outpath), inpath]
993        monkeypatch.setattr(
994            ttx,
995            "process",
996            (lambda x, y: raise_exception(Exception("Test error"))),
997        )
998        ttx.main(args)
999
1000    assert "Unhandled exception has occurred" in caplog.text
1001
1002
1003def test_main_ttf_dump_stdin_to_stdout(tmp_path):
1004    inpath = Path("Tests").joinpath("ttx", "data", "TestTTF.ttf")
1005    outpath = tmp_path / "TestTTF.ttx"
1006    args = [sys.executable, "-m", "fontTools.ttx", "-q", "-o", "-", "-"]
1007    with inpath.open("rb") as infile, outpath.open("w", encoding="utf-8") as outfile:
1008        subprocess.run(args, check=True, stdin=infile, stdout=outfile)
1009    assert outpath.is_file()
1010
1011
1012def test_main_ttx_compile_stdin_to_stdout(tmp_path):
1013    inpath = Path("Tests").joinpath("ttx", "data", "TestTTF.ttx")
1014    outpath = tmp_path / "TestTTF.ttf"
1015    args = [sys.executable, "-m", "fontTools.ttx", "-q", "-o", "-", "-"]
1016    with inpath.open("r", encoding="utf-8") as infile, outpath.open("wb") as outfile:
1017        subprocess.run(args, check=True, stdin=infile, stdout=outfile)
1018    assert outpath.is_file()
1019
1020
1021def test_roundtrip_DSIG_split_at_XML_parse_buffer_size(tmp_path):
1022    inpath = Path("Tests").joinpath(
1023        "ttx", "data", "roundtrip_DSIG_split_at_XML_parse_buffer_size.ttx"
1024    )
1025    font = TTFont()
1026    font.importXML(inpath)
1027    font["DMMY"] = DefaultTable(tag="DMMY")
1028    # just enough dummy bytes to hit the cut off point whereby DSIG data gets
1029    # split into two chunks and triggers the bug from
1030    # https://github.com/fonttools/fonttools/issues/2614
1031    font["DMMY"].data = b"\x01\x02\x03\x04" * 2438
1032    font.saveXML(tmp_path / "roundtrip_DSIG_split_at_XML_parse_buffer_size.ttx")
1033
1034    outpath = tmp_path / "font.ttf"
1035    args = [
1036        sys.executable,
1037        "-m",
1038        "fontTools.ttx",
1039        "-q",
1040        "-o",
1041        str(outpath),
1042        str(tmp_path / "roundtrip_DSIG_split_at_XML_parse_buffer_size.ttx"),
1043    ]
1044    subprocess.run(args, check=True)
1045
1046    assert outpath.is_file()
1047    assert TTFont(outpath)["DSIG"].signatureRecords[0].pkcs7 == base64.b64decode(
1048        b"0000000100000000"
1049    )
1050
1051
1052# ---------------------------
1053# support functions for tests
1054# ---------------------------
1055
1056
1057def raise_exception(exception):
1058    raise exception
1059