xref: /aosp_15_r20/external/fonttools/Tests/subset/subset_test.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
1import io
2import fontTools.ttLib.tables.otBase
3from fontTools.misc.testTools import getXML, stripVariableItemsFromTTX
4from fontTools.misc.textTools import tobytes, tostr
5from fontTools import subset
6from fontTools.fontBuilder import FontBuilder
7from fontTools.pens.ttGlyphPen import TTGlyphPen
8from fontTools.ttLib import TTFont, newTable
9from fontTools.ttLib.tables import otTables as ot
10from fontTools.misc.loggingTools import CapturingLogHandler
11from fontTools.subset.svg import etree
12import difflib
13import logging
14import os
15import shutil
16import sys
17import tempfile
18import unittest
19import pathlib
20import pytest
21
22
23class SubsetTest:
24    @classmethod
25    def setup_class(cls):
26        cls.tempdir = None
27        cls.num_tempfiles = 0
28
29    @classmethod
30    def teardown_class(cls):
31        if cls.tempdir:
32            shutil.rmtree(cls.tempdir, ignore_errors=True)
33
34    @staticmethod
35    def getpath(*testfile):
36        path, _ = os.path.split(__file__)
37        return os.path.join(path, "data", *testfile)
38
39    @classmethod
40    def temp_path(cls, suffix):
41        if not cls.tempdir:
42            cls.tempdir = tempfile.mkdtemp()
43        cls.num_tempfiles += 1
44        return os.path.join(cls.tempdir, "tmp%d%s" % (cls.num_tempfiles, suffix))
45
46    @staticmethod
47    def read_ttx(path):
48        with open(path, "r", encoding="utf-8") as f:
49            ttx = f.read()
50        # don't care whether TTF or OTF, thus strip sfntVersion as well
51        return stripVariableItemsFromTTX(ttx, sfntVersion=True).splitlines(True)
52
53    def expect_ttx(self, font, expected_ttx, tables=None):
54        path = self.temp_path(suffix=".ttx")
55        font.saveXML(path, tables=tables)
56        actual = self.read_ttx(path)
57        expected = self.read_ttx(expected_ttx)
58        if actual != expected:
59            for line in difflib.unified_diff(
60                expected, actual, fromfile=expected_ttx, tofile=path
61            ):
62                sys.stdout.write(line)
63            pytest.fail("TTX output is different from expected")
64
65    def compile_font(self, path, suffix):
66        savepath = self.temp_path(suffix=suffix)
67        font = TTFont(recalcBBoxes=False, recalcTimestamp=False)
68        font.importXML(path)
69        font.save(savepath, reorderTables=None)
70        return savepath
71
72    # -----
73    # Tests
74    # -----
75
76    def test_layout_scripts(self):
77        fontpath = self.compile_font(self.getpath("layout_scripts.ttx"), ".otf")
78        subsetpath = self.temp_path(".otf")
79        subset.main(
80            [
81                fontpath,
82                "--glyphs=*",
83                "--layout-features=*",
84                "--layout-scripts=latn,arab.URD,arab.dflt",
85                "--output-file=%s" % subsetpath,
86            ]
87        )
88        subsetfont = TTFont(subsetpath)
89        self.expect_ttx(
90            subsetfont, self.getpath("expect_layout_scripts.ttx"), ["GPOS", "GSUB"]
91        )
92
93    def test_no_notdef_outline_otf(self):
94        fontpath = self.compile_font(self.getpath("TestOTF-Regular.ttx"), ".otf")
95        subsetpath = self.temp_path(".otf")
96        subset.main(
97            [
98                fontpath,
99                "--no-notdef-outline",
100                "--gids=0",
101                "--output-file=%s" % subsetpath,
102            ]
103        )
104        subsetfont = TTFont(subsetpath)
105        self.expect_ttx(
106            subsetfont, self.getpath("expect_no_notdef_outline_otf.ttx"), ["CFF "]
107        )
108
109    def test_no_notdef_outline_cid(self):
110        fontpath = self.compile_font(self.getpath("TestCID-Regular.ttx"), ".otf")
111        subsetpath = self.temp_path(".otf")
112        subset.main(
113            [
114                fontpath,
115                "--no-notdef-outline",
116                "--gids=0",
117                "--output-file=%s" % subsetpath,
118            ]
119        )
120        subsetfont = TTFont(subsetpath)
121        self.expect_ttx(
122            subsetfont, self.getpath("expect_no_notdef_outline_cid.ttx"), ["CFF "]
123        )
124
125    def test_no_notdef_outline_ttf(self):
126        fontpath = self.compile_font(self.getpath("TestTTF-Regular.ttx"), ".ttf")
127        subsetpath = self.temp_path(".ttf")
128        subset.main(
129            [
130                fontpath,
131                "--no-notdef-outline",
132                "--gids=0",
133                "--output-file=%s" % subsetpath,
134            ]
135        )
136        subsetfont = TTFont(subsetpath)
137        self.expect_ttx(
138            subsetfont,
139            self.getpath("expect_no_notdef_outline_ttf.ttx"),
140            ["glyf", "hmtx"],
141        )
142
143    def test_subset_ankr(self):
144        fontpath = self.compile_font(self.getpath("TestANKR.ttx"), ".ttf")
145        subsetpath = self.temp_path(".ttf")
146        subset.main([fontpath, "--glyphs=one", "--output-file=%s" % subsetpath])
147        subsetfont = TTFont(subsetpath)
148        self.expect_ttx(subsetfont, self.getpath("expect_ankr.ttx"), ["ankr"])
149
150    def test_subset_ankr_remove(self):
151        fontpath = self.compile_font(self.getpath("TestANKR.ttx"), ".ttf")
152        subsetpath = self.temp_path(".ttf")
153        subset.main([fontpath, "--glyphs=two", "--output-file=%s" % subsetpath])
154        assert "ankr" not in TTFont(subsetpath)
155
156    def test_subset_bsln_format_0(self):
157        fontpath = self.compile_font(self.getpath("TestBSLN-0.ttx"), ".ttf")
158        subsetpath = self.temp_path(".ttf")
159        subset.main([fontpath, "--glyphs=one", "--output-file=%s" % subsetpath])
160        subsetfont = TTFont(subsetpath)
161        self.expect_ttx(subsetfont, self.getpath("expect_bsln_0.ttx"), ["bsln"])
162
163    def test_subset_bsln_format_0_from_format_1(self):
164        # TestBSLN-1 defines the ideographic baseline to be the font's default,
165        # and specifies that glyphs {.notdef, zero, one, two} use the roman
166        # baseline instead of the default ideographic baseline. As we request
167        # a subsetted font with {zero, one} and the implicit .notdef, all
168        # glyphs in the resulting font use the Roman baseline. In this case,
169        # we expect a format 0 'bsln' table because it is the most compact.
170        fontpath = self.compile_font(self.getpath("TestBSLN-1.ttx"), ".ttf")
171        subsetpath = self.temp_path(".ttf")
172        subset.main(
173            [fontpath, "--unicodes=U+0030-0031", "--output-file=%s" % subsetpath]
174        )
175        subsetfont = TTFont(subsetpath)
176        self.expect_ttx(subsetfont, self.getpath("expect_bsln_0.ttx"), ["bsln"])
177
178    def test_subset_bsln_format_1(self):
179        # TestBSLN-1 defines the ideographic baseline to be the font's default,
180        # and specifies that glyphs {.notdef, zero, one, two} use the roman
181        # baseline instead of the default ideographic baseline. We request
182        # a subset where the majority of glyphs use the roman baseline,
183        # but one single glyph (uni2EA2) is ideographic. In the resulting
184        # subsetted font, we expect a format 1 'bsln' table whose default
185        # is Roman, but with an override that uses the ideographic baseline
186        # for uni2EA2.
187        fontpath = self.compile_font(self.getpath("TestBSLN-1.ttx"), ".ttf")
188        subsetpath = self.temp_path(".ttf")
189        subset.main(
190            [fontpath, "--unicodes=U+0030-0031,U+2EA2", "--output-file=%s" % subsetpath]
191        )
192        subsetfont = TTFont(subsetpath)
193        self.expect_ttx(subsetfont, self.getpath("expect_bsln_1.ttx"), ["bsln"])
194
195    def test_subset_bsln_format_2(self):
196        # The 'bsln' table in TestBSLN-2 refers to control points in glyph 'P'
197        # for defining its baselines. Therefore, the subsetted font should
198        # include this glyph even though it is not requested explicitly.
199        fontpath = self.compile_font(self.getpath("TestBSLN-2.ttx"), ".ttf")
200        subsetpath = self.temp_path(".ttf")
201        subset.main([fontpath, "--glyphs=one", "--output-file=%s" % subsetpath])
202        subsetfont = TTFont(subsetpath)
203        self.expect_ttx(subsetfont, self.getpath("expect_bsln_2.ttx"), ["bsln"])
204
205    def test_subset_bsln_format_2_from_format_3(self):
206        # TestBSLN-3 defines the ideographic baseline to be the font's default,
207        # and specifies that glyphs {.notdef, zero, one, two, P} use the roman
208        # baseline instead of the default ideographic baseline. As we request
209        # a subsetted font with zero and the implicit .notdef and P for
210        # baseline measurement, all glyphs in the resulting font use the Roman
211        # baseline. In this case, we expect a format 2 'bsln' table because it
212        # is the most compact encoding.
213        fontpath = self.compile_font(self.getpath("TestBSLN-3.ttx"), ".ttf")
214        subsetpath = self.temp_path(".ttf")
215        subset.main([fontpath, "--unicodes=U+0030", "--output-file=%s" % subsetpath])
216        subsetfont = TTFont(subsetpath)
217        self.expect_ttx(subsetfont, self.getpath("expect_bsln_2.ttx"), ["bsln"])
218
219    def test_subset_bsln_format_3(self):
220        # TestBSLN-3 defines the ideographic baseline to be the font's default,
221        # and specifies that glyphs {.notdef, zero, one, two} use the roman
222        # baseline instead of the default ideographic baseline. We request
223        # a subset where the majority of glyphs use the roman baseline,
224        # but one single glyph (uni2EA2) is ideographic. In the resulting
225        # subsetted font, we expect a format 1 'bsln' table whose default
226        # is Roman, but with an override that uses the ideographic baseline
227        # for uni2EA2.
228        fontpath = self.compile_font(self.getpath("TestBSLN-3.ttx"), ".ttf")
229        subsetpath = self.temp_path(".ttf")
230        subset.main(
231            [fontpath, "--unicodes=U+0030-0031,U+2EA2", "--output-file=%s" % subsetpath]
232        )
233        subsetfont = TTFont(subsetpath)
234        self.expect_ttx(subsetfont, self.getpath("expect_bsln_3.ttx"), ["bsln"])
235
236    def test_subset_clr(self):
237        fontpath = self.compile_font(self.getpath("TestCLR-Regular.ttx"), ".ttf")
238        subsetpath = self.temp_path(".ttf")
239        subset.main([fontpath, "--glyphs=smileface", "--output-file=%s" % subsetpath])
240        subsetfont = TTFont(subsetpath)
241        self.expect_ttx(
242            subsetfont,
243            self.getpath("expect_keep_colr.ttx"),
244            ["GlyphOrder", "hmtx", "glyf", "COLR", "CPAL"],
245        )
246
247    def test_subset_gvar(self):
248        fontpath = self.compile_font(self.getpath("TestGVAR.ttx"), ".ttf")
249        subsetpath = self.temp_path(".ttf")
250        subset.main(
251            [fontpath, "--unicodes=U+002B,U+2212", "--output-file=%s" % subsetpath]
252        )
253        subsetfont = TTFont(subsetpath)
254        self.expect_ttx(
255            subsetfont,
256            self.getpath("expect_keep_gvar.ttx"),
257            ["GlyphOrder", "avar", "fvar", "gvar", "name"],
258        )
259
260    def test_subset_gvar_notdef_outline(self):
261        fontpath = self.compile_font(self.getpath("TestGVAR.ttx"), ".ttf")
262        subsetpath = self.temp_path(".ttf")
263        subset.main(
264            [
265                fontpath,
266                "--unicodes=U+0030",
267                "--notdef_outline",
268                "--output-file=%s" % subsetpath,
269            ]
270        )
271        subsetfont = TTFont(subsetpath)
272        self.expect_ttx(
273            subsetfont,
274            self.getpath("expect_keep_gvar_notdef_outline.ttx"),
275            ["GlyphOrder", "avar", "fvar", "gvar", "name"],
276        )
277
278    def test_subset_lcar_remove(self):
279        fontpath = self.compile_font(self.getpath("TestLCAR-0.ttx"), ".ttf")
280        subsetpath = self.temp_path(".ttf")
281        subset.main([fontpath, "--glyphs=one", "--output-file=%s" % subsetpath])
282        subsetfont = TTFont(subsetpath)
283        assert "lcar" not in subsetfont
284
285    def test_subset_lcar_format_0(self):
286        fontpath = self.compile_font(self.getpath("TestLCAR-0.ttx"), ".ttf")
287        subsetpath = self.temp_path(".ttf")
288        subset.main([fontpath, "--unicodes=U+FB01", "--output-file=%s" % subsetpath])
289        subsetfont = TTFont(subsetpath)
290        self.expect_ttx(subsetfont, self.getpath("expect_lcar_0.ttx"), ["lcar"])
291
292    def test_subset_lcar_format_1(self):
293        fontpath = self.compile_font(self.getpath("TestLCAR-1.ttx"), ".ttf")
294        subsetpath = self.temp_path(".ttf")
295        subset.main([fontpath, "--unicodes=U+FB01", "--output-file=%s" % subsetpath])
296        subsetfont = TTFont(subsetpath)
297        self.expect_ttx(subsetfont, self.getpath("expect_lcar_1.ttx"), ["lcar"])
298
299    def test_subset_math(self):
300        fontpath = self.compile_font(self.getpath("TestMATH-Regular.ttx"), ".ttf")
301        subsetpath = self.temp_path(".ttf")
302        subset.main(
303            [
304                fontpath,
305                "--unicodes=U+0041,U+0028,U+0302,U+1D400,U+1D435",
306                "--output-file=%s" % subsetpath,
307            ]
308        )
309        subsetfont = TTFont(subsetpath)
310        self.expect_ttx(
311            subsetfont,
312            self.getpath("expect_keep_math.ttx"),
313            ["GlyphOrder", "CFF ", "MATH", "hmtx"],
314        )
315
316    def test_subset_math_partial(self):
317        fontpath = self.compile_font(self.getpath("test_math_partial.ttx"), ".ttf")
318        subsetpath = self.temp_path(".ttf")
319        subset.main([fontpath, "--text=A", "--output-file=%s" % subsetpath])
320        subsetfont = TTFont(subsetpath)
321        self.expect_ttx(subsetfont, self.getpath("expect_math_partial.ttx"), ["MATH"])
322
323    def test_subset_opbd_remove(self):
324        # In the test font, only the glyphs 'A' and 'zero' have an entry in
325        # the Optical Bounds table. When subsetting, we do not request any
326        # of those glyphs. Therefore, the produced subsetted font should
327        # not contain an 'opbd' table.
328        fontpath = self.compile_font(self.getpath("TestOPBD-0.ttx"), ".ttf")
329        subsetpath = self.temp_path(".ttf")
330        subset.main([fontpath, "--glyphs=one", "--output-file=%s" % subsetpath])
331        subsetfont = TTFont(subsetpath)
332        assert "opbd" not in subsetfont
333
334    def test_subset_opbd_format_0(self):
335        fontpath = self.compile_font(self.getpath("TestOPBD-0.ttx"), ".ttf")
336        subsetpath = self.temp_path(".ttf")
337        subset.main([fontpath, "--glyphs=A", "--output-file=%s" % subsetpath])
338        subsetfont = TTFont(subsetpath)
339        self.expect_ttx(subsetfont, self.getpath("expect_opbd_0.ttx"), ["opbd"])
340
341    def test_subset_opbd_format_1(self):
342        fontpath = self.compile_font(self.getpath("TestOPBD-1.ttx"), ".ttf")
343        subsetpath = self.temp_path(".ttf")
344        subset.main([fontpath, "--glyphs=A", "--output-file=%s" % subsetpath])
345        subsetfont = TTFont(subsetpath)
346        self.expect_ttx(subsetfont, self.getpath("expect_opbd_1.ttx"), ["opbd"])
347
348    def test_subset_prop_remove_default_zero(self):
349        # If all glyphs have an AAT glyph property with value 0,
350        # the "prop" table should be removed from the subsetted font.
351        fontpath = self.compile_font(self.getpath("TestPROP.ttx"), ".ttf")
352        subsetpath = self.temp_path(".ttf")
353        subset.main([fontpath, "--unicodes=U+0041", "--output-file=%s" % subsetpath])
354        subsetfont = TTFont(subsetpath)
355        assert "prop" not in subsetfont
356
357    def test_subset_prop_0(self):
358        # If all glyphs share the same AAT glyph properties, the "prop" table
359        # in the subsetted font should use format 0.
360        #
361        # Unless the shared value is zero, in which case the subsetted font
362        # should have no "prop" table at all. But that case has already been
363        # tested above in test_subset_prop_remove_default_zero().
364        fontpath = self.compile_font(self.getpath("TestPROP.ttx"), ".ttf")
365        subsetpath = self.temp_path(".ttf")
366        subset.main(
367            [
368                fontpath,
369                "--unicodes=U+0030-0032",
370                "--no-notdef-glyph",
371                "--output-file=%s" % subsetpath,
372            ]
373        )
374        subsetfont = TTFont(subsetpath)
375        self.expect_ttx(subsetfont, self.getpath("expect_prop_0.ttx"), ["prop"])
376
377    def test_subset_prop_1(self):
378        # If not all glyphs share the same AAT glyph properties, the subsetted
379        # font should contain a "prop" table in format 1. To save space, the
380        # DefaultProperties should be set to the most frequent value.
381        fontpath = self.compile_font(self.getpath("TestPROP.ttx"), ".ttf")
382        subsetpath = self.temp_path(".ttf")
383        subset.main(
384            [
385                fontpath,
386                "--unicodes=U+0030-0032",
387                "--notdef-outline",
388                "--output-file=%s" % subsetpath,
389            ]
390        )
391        subsetfont = TTFont(subsetpath)
392        self.expect_ttx(subsetfont, self.getpath("expect_prop_1.ttx"), ["prop"])
393
394    def test_options(self):
395        # https://github.com/fonttools/fonttools/issues/413
396        opt1 = subset.Options()
397        assert "Xyz-" not in opt1.layout_features
398        opt2 = subset.Options()
399        opt2.layout_features.append("Xyz-")
400        assert "Xyz-" in opt2.layout_features
401        assert "Xyz-" not in opt1.layout_features
402
403    def test_google_color(self):
404        fontpath = self.compile_font(self.getpath("google_color.ttx"), ".ttf")
405        subsetpath = self.temp_path(".ttf")
406        subset.main([fontpath, "--gids=0,1", "--output-file=%s" % subsetpath])
407        subsetfont = TTFont(subsetpath)
408        assert "CBDT" in subsetfont
409        assert "CBLC" in subsetfont
410        assert "x" in subsetfont["CBDT"].strikeData[0]
411        assert "y" not in subsetfont["CBDT"].strikeData[0]
412
413    def test_google_color_all(self):
414        fontpath = self.compile_font(self.getpath("google_color.ttx"), ".ttf")
415        subsetpath = self.temp_path(".ttf")
416        subset.main([fontpath, "--unicodes=*", "--output-file=%s" % subsetpath])
417        subsetfont = TTFont(subsetpath)
418        assert "x" in subsetfont["CBDT"].strikeData[0]
419        assert "y" in subsetfont["CBDT"].strikeData[0]
420
421    def test_sbix(self):
422        fontpath = self.compile_font(self.getpath("sbix.ttx"), ".ttf")
423        subsetpath = self.temp_path(".ttf")
424        subset.main([fontpath, "--gids=0,1", "--output-file=%s" % subsetpath])
425        subsetfont = TTFont(subsetpath)
426        self.expect_ttx(subsetfont, self.getpath("expect_sbix.ttx"), ["sbix"])
427
428    def test_varComposite(self):
429        fontpath = self.getpath("..", "..", "ttLib", "data", "varc-ac00-ac01.ttf")
430        origfont = TTFont(fontpath)
431        assert len(origfont.getGlyphOrder()) == 6
432        subsetpath = self.temp_path(".ttf")
433        subset.main([fontpath, "--unicodes=ac00", "--output-file=%s" % subsetpath])
434        subsetfont = TTFont(subsetpath)
435        assert len(subsetfont.getGlyphOrder()) == 4
436        subset.main([fontpath, "--unicodes=ac01", "--output-file=%s" % subsetpath])
437        subsetfont = TTFont(subsetpath)
438        assert len(subsetfont.getGlyphOrder()) == 5
439
440    def test_timing_publishes_parts(self):
441        fontpath = self.compile_font(self.getpath("TestTTF-Regular.ttx"), ".ttf")
442
443        options = subset.Options()
444        options.timing = True
445        subsetter = subset.Subsetter(options)
446        subsetter.populate(text="ABC")
447        font = TTFont(fontpath)
448        with CapturingLogHandler("fontTools.subset.timer", logging.DEBUG) as captor:
449            subsetter.subset(font)
450        logs = captor.records
451
452        assert len(logs) > 5
453        assert len(logs) == len(
454            [l for l in logs if "msg" in l.args and "time" in l.args]
455        )
456        # Look for a few things we know should happen
457        assert filter(lambda l: l.args["msg"] == "load 'cmap'", logs)
458        assert filter(lambda l: l.args["msg"] == "subset 'cmap'", logs)
459        assert filter(lambda l: l.args["msg"] == "subset 'glyf'", logs)
460
461    def test_passthrough_tables(self):
462        fontpath = self.compile_font(self.getpath("TestTTF-Regular.ttx"), ".ttf")
463        font = TTFont(fontpath)
464        unknown_tag = "ZZZZ"
465        unknown_table = newTable(unknown_tag)
466        unknown_table.data = b"\0" * 10
467        font[unknown_tag] = unknown_table
468        font.save(fontpath)
469
470        subsetpath = self.temp_path(".ttf")
471        subset.main([fontpath, "--output-file=%s" % subsetpath])
472        subsetfont = TTFont(subsetpath)
473
474        # tables we can't subset are dropped by default
475        assert unknown_tag not in subsetfont
476
477        subsetpath = self.temp_path(".ttf")
478        subset.main([fontpath, "--passthrough-tables", "--output-file=%s" % subsetpath])
479        subsetfont = TTFont(subsetpath)
480
481        # unknown tables are kept if --passthrough-tables option is passed
482        assert unknown_tag in subsetfont
483
484    def test_non_BMP_text_arg_input(self):
485        fontpath = self.compile_font(
486            self.getpath("TestTTF-Regular_non_BMP_char.ttx"), ".ttf"
487        )
488        subsetpath = self.temp_path(".ttf")
489        text = tostr("A\U0001F6D2", encoding="utf-8")
490
491        subset.main([fontpath, "--text=%s" % text, "--output-file=%s" % subsetpath])
492        subsetfont = TTFont(subsetpath)
493
494        assert subsetfont["maxp"].numGlyphs == 3
495        assert subsetfont.getGlyphOrder() == [".notdef", "A", "u1F6D2"]
496
497    def test_non_BMP_text_file_input(self):
498        fontpath = self.compile_font(
499            self.getpath("TestTTF-Regular_non_BMP_char.ttx"), ".ttf"
500        )
501        subsetpath = self.temp_path(".ttf")
502        text = tobytes("A\U0001F6D2", encoding="utf-8")
503        with tempfile.NamedTemporaryFile(delete=False) as tmp:
504            tmp.write(text)
505
506        try:
507            subset.main(
508                [fontpath, "--text-file=%s" % tmp.name, "--output-file=%s" % subsetpath]
509            )
510            subsetfont = TTFont(subsetpath)
511        finally:
512            os.remove(tmp.name)
513
514        assert subsetfont["maxp"].numGlyphs == 3
515        assert subsetfont.getGlyphOrder() == [".notdef", "A", "u1F6D2"]
516
517    def test_no_hinting_CFF(self):
518        ttxpath = self.getpath("Lobster.subset.ttx")
519        fontpath = self.compile_font(ttxpath, ".otf")
520        subsetpath = self.temp_path(".otf")
521        subset.main(
522            [
523                fontpath,
524                "--no-hinting",
525                "--notdef-outline",
526                "--output-file=%s" % subsetpath,
527                "*",
528            ]
529        )
530        subsetfont = TTFont(subsetpath)
531        self.expect_ttx(subsetfont, self.getpath("expect_no_hinting_CFF.ttx"), ["CFF "])
532
533    def test_desubroutinize_CFF(self):
534        ttxpath = self.getpath("Lobster.subset.ttx")
535        fontpath = self.compile_font(ttxpath, ".otf")
536        subsetpath = self.temp_path(".otf")
537        subset.main(
538            [
539                fontpath,
540                "--desubroutinize",
541                "--notdef-outline",
542                "--output-file=%s" % subsetpath,
543                "*",
544            ]
545        )
546        subsetfont = TTFont(subsetpath)
547        self.expect_ttx(
548            subsetfont, self.getpath("expect_desubroutinize_CFF.ttx"), ["CFF "]
549        )
550
551    def test_desubroutinize_hinted_subrs_CFF(self):
552        ttxpath = self.getpath("test_hinted_subrs_CFF.ttx")
553        fontpath = self.compile_font(ttxpath, ".otf")
554        subsetpath = self.temp_path(".otf")
555        subset.main(
556            [
557                fontpath,
558                "--desubroutinize",
559                "--notdef-outline",
560                "--output-file=%s" % subsetpath,
561                "*",
562            ]
563        )
564        subsetfont = TTFont(subsetpath)
565        self.expect_ttx(
566            subsetfont, self.getpath("test_hinted_subrs_CFF.desub.ttx"), ["CFF "]
567        )
568
569    def test_desubroutinize_cntrmask_CFF(self):
570        ttxpath = self.getpath("test_cntrmask_CFF.ttx")
571        fontpath = self.compile_font(ttxpath, ".otf")
572        subsetpath = self.temp_path(".otf")
573        subset.main(
574            [
575                fontpath,
576                "--desubroutinize",
577                "--notdef-outline",
578                "--output-file=%s" % subsetpath,
579                "*",
580            ]
581        )
582        subsetfont = TTFont(subsetpath)
583        self.expect_ttx(
584            subsetfont, self.getpath("test_cntrmask_CFF.desub.ttx"), ["CFF "]
585        )
586
587    def test_no_hinting_desubroutinize_CFF(self):
588        ttxpath = self.getpath("test_hinted_subrs_CFF.ttx")
589        fontpath = self.compile_font(ttxpath, ".otf")
590        subsetpath = self.temp_path(".otf")
591        subset.main(
592            [
593                fontpath,
594                "--no-hinting",
595                "--desubroutinize",
596                "--notdef-outline",
597                "--output-file=%s" % subsetpath,
598                "*",
599            ]
600        )
601        subsetfont = TTFont(subsetpath)
602        self.expect_ttx(
603            subsetfont,
604            self.getpath("expect_no_hinting_desubroutinize_CFF.ttx"),
605            ["CFF "],
606        )
607
608    def test_no_hinting_TTF(self):
609        fontpath = self.compile_font(self.getpath("TestTTF-Regular.ttx"), ".ttf")
610        subsetpath = self.temp_path(".ttf")
611        subset.main(
612            [
613                fontpath,
614                "--no-hinting",
615                "--notdef-outline",
616                "--output-file=%s" % subsetpath,
617                "*",
618            ]
619        )
620        subsetfont = TTFont(subsetpath)
621        self.expect_ttx(
622            subsetfont, self.getpath("expect_no_hinting_TTF.ttx"), ["glyf", "maxp"]
623        )
624        for tag in subset.Options().hinting_tables:
625            assert tag not in subsetfont
626
627    def test_notdef_width_cid(self):
628        # https://github.com/fonttools/fonttools/pull/845
629        fontpath = self.compile_font(self.getpath("NotdefWidthCID-Regular.ttx"), ".otf")
630        subsetpath = self.temp_path(".otf")
631        subset.main(
632            [
633                fontpath,
634                "--no-notdef-outline",
635                "--gids=0,1",
636                "--output-file=%s" % subsetpath,
637            ]
638        )
639        subsetfont = TTFont(subsetpath)
640        self.expect_ttx(
641            subsetfont, self.getpath("expect_notdef_width_cid.ttx"), ["CFF "]
642        )
643
644    def test_recalc_bounds_ttf(self):
645        ttxpath = self.getpath("TestTTF-Regular.ttx")
646        font = TTFont()
647        font.importXML(ttxpath)
648        head = font["head"]
649        bounds = [head.xMin, head.yMin, head.xMax, head.yMax]
650
651        fontpath = self.compile_font(ttxpath, ".ttf")
652        subsetpath = self.temp_path(".ttf")
653
654        # by default, the subsetter does not recalculate the bounding box
655        subset.main([fontpath, "--output-file=%s" % subsetpath, "*"])
656        head = TTFont(subsetpath)["head"]
657        assert bounds == [head.xMin, head.yMin, head.xMax, head.yMax]
658
659        subset.main([fontpath, "--recalc-bounds", "--output-file=%s" % subsetpath, "*"])
660        head = TTFont(subsetpath)["head"]
661        bounds = [132, 304, 365, 567]
662        assert bounds == [head.xMin, head.yMin, head.xMax, head.yMax]
663
664    def test_recalc_bounds_otf(self):
665        ttxpath = self.getpath("TestOTF-Regular.ttx")
666        font = TTFont()
667        font.importXML(ttxpath)
668        head = font["head"]
669        bounds = [head.xMin, head.yMin, head.xMax, head.yMax]
670
671        fontpath = self.compile_font(ttxpath, ".otf")
672        subsetpath = self.temp_path(".otf")
673
674        # by default, the subsetter does not recalculate the bounding box
675        subset.main([fontpath, "--output-file=%s" % subsetpath, "*"])
676        head = TTFont(subsetpath)["head"]
677        assert bounds == [head.xMin, head.yMin, head.xMax, head.yMax]
678
679        subset.main([fontpath, "--recalc-bounds", "--output-file=%s" % subsetpath, "*"])
680        head = TTFont(subsetpath)["head"]
681        bounds = [132, 304, 365, 567]
682        assert bounds == [head.xMin, head.yMin, head.xMax, head.yMax]
683
684    def test_recalc_timestamp_ttf(self):
685        ttxpath = self.getpath("TestTTF-Regular.ttx")
686        font = TTFont()
687        font.importXML(ttxpath)
688        modified = font["head"].modified
689        fontpath = self.compile_font(ttxpath, ".ttf")
690        subsetpath = self.temp_path(".ttf")
691
692        # by default, the subsetter does not recalculate the modified timestamp
693        subset.main([fontpath, "--output-file=%s" % subsetpath, "*"])
694        assert modified == TTFont(subsetpath)["head"].modified
695
696        subset.main(
697            [fontpath, "--recalc-timestamp", "--output-file=%s" % subsetpath, "*"]
698        )
699        assert modified < TTFont(subsetpath)["head"].modified
700
701    def test_recalc_timestamp_otf(self):
702        ttxpath = self.getpath("TestOTF-Regular.ttx")
703        font = TTFont()
704        font.importXML(ttxpath)
705        modified = font["head"].modified
706        fontpath = self.compile_font(ttxpath, ".otf")
707        subsetpath = self.temp_path(".otf")
708
709        # by default, the subsetter does not recalculate the modified timestamp
710        subset.main([fontpath, "--output-file=%s" % subsetpath, "*"])
711        assert modified == TTFont(subsetpath)["head"].modified
712
713        subset.main(
714            [fontpath, "--recalc-timestamp", "--output-file=%s" % subsetpath, "*"]
715        )
716        assert modified < TTFont(subsetpath)["head"].modified
717
718    def test_recalc_max_context(self):
719        ttxpath = self.getpath("Lobster.subset.ttx")
720        font = TTFont()
721        font.importXML(ttxpath)
722        max_context = font["OS/2"].usMaxContext
723        fontpath = self.compile_font(ttxpath, ".otf")
724        subsetpath = self.temp_path(".otf")
725
726        # by default, the subsetter does not recalculate the usMaxContext
727        subset.main(
728            [fontpath, "--drop-tables+=GSUB,GPOS", "--output-file=%s" % subsetpath]
729        )
730        assert max_context == TTFont(subsetpath)["OS/2"].usMaxContext
731
732        subset.main(
733            [
734                fontpath,
735                "--recalc-max-context",
736                "--drop-tables+=GSUB,GPOS",
737                "--output-file=%s" % subsetpath,
738            ]
739        )
740        assert 0 == TTFont(subsetpath)["OS/2"].usMaxContext
741
742    def test_retain_gids_ttf(self):
743        fontpath = self.compile_font(self.getpath("TestTTF-Regular.ttx"), ".ttf")
744        font = TTFont(fontpath)
745
746        assert font["hmtx"]["A"] == (500, 132)
747        assert font["hmtx"]["B"] == (400, 132)
748
749        assert font["glyf"]["A"].numberOfContours > 0
750        assert font["glyf"]["B"].numberOfContours > 0
751
752        subsetpath = self.temp_path(".ttf")
753        subset.main(
754            [
755                fontpath,
756                "--retain-gids",
757                "--output-file=%s" % subsetpath,
758                "--glyph-names",
759                "B",
760            ]
761        )
762        subsetfont = TTFont(subsetpath)
763
764        assert subsetfont.getGlyphOrder() == font.getGlyphOrder()[0:3]
765
766        hmtx = subsetfont["hmtx"]
767        assert hmtx["A"] == (0, 0)
768        assert hmtx["B"] == (400, 132)
769
770        glyf = subsetfont["glyf"]
771        assert glyf["A"].numberOfContours == 0
772        assert glyf["B"].numberOfContours > 0
773
774    def test_retain_gids_cff(self):
775        fontpath = self.compile_font(self.getpath("TestOTF-Regular.ttx"), ".otf")
776        font = TTFont(fontpath)
777
778        assert font["hmtx"]["A"] == (500, 132)
779        assert font["hmtx"]["B"] == (400, 132)
780        assert font["hmtx"]["C"] == (500, 0)
781
782        font["CFF "].cff[0].decompileAllCharStrings()
783        cs = font["CFF "].cff[0].CharStrings
784        assert len(cs["A"].program) > 0
785        assert len(cs["B"].program) > 0
786        assert len(cs["C"].program) > 0
787
788        subsetpath = self.temp_path(".otf")
789        subset.main(
790            [
791                fontpath,
792                "--retain-gids",
793                "--output-file=%s" % subsetpath,
794                "--glyph-names",
795                "B",
796            ]
797        )
798        subsetfont = TTFont(subsetpath)
799
800        assert subsetfont.getGlyphOrder() == font.getGlyphOrder()[0:3]
801
802        hmtx = subsetfont["hmtx"]
803        assert hmtx["A"] == (0, 0)
804        assert hmtx["B"] == (400, 132)
805
806        subsetfont["CFF "].cff[0].decompileAllCharStrings()
807        cs = subsetfont["CFF "].cff[0].CharStrings
808
809        assert cs["A"].program == ["endchar"]
810        assert len(cs["B"].program) > 0
811
812    def test_retain_gids_cff2(self):
813        ttx_path = self.getpath(
814            "../../varLib/data/master_ttx_varfont_otf/TestCFF2VF.ttx"
815        )
816        fontpath = self.compile_font(ttx_path, ".otf")
817        font = TTFont(fontpath)
818
819        assert font["hmtx"]["A"] == (600, 31)
820        assert font["hmtx"]["T"] == (600, 41)
821
822        font["CFF2"].cff[0].decompileAllCharStrings()
823        cs = font["CFF2"].cff[0].CharStrings
824        assert len(cs["A"].program) > 0
825        assert len(cs["T"].program) > 0
826
827        subsetpath = self.temp_path(".otf")
828        subset.main(
829            [
830                fontpath,
831                "--retain-gids",
832                "--output-file=%s" % subsetpath,
833                "T",
834            ]
835        )
836        subsetfont = TTFont(subsetpath)
837
838        assert len(subsetfont.getGlyphOrder()) == len(font.getGlyphOrder()[0:3])
839
840        hmtx = subsetfont["hmtx"]
841        assert hmtx["glyph00001"] == (0, 0)
842        assert hmtx["T"] == (600, 41)
843
844        subsetfont["CFF2"].cff[0].decompileAllCharStrings()
845        cs = subsetfont["CFF2"].cff[0].CharStrings
846        assert cs["glyph00001"].program == []
847        assert len(cs["T"].program) > 0
848
849    def test_HVAR_VVAR(self):
850        fontpath = self.compile_font(self.getpath("TestHVVAR.ttx"), ".ttf")
851        subsetpath = self.temp_path(".ttf")
852        subset.main([fontpath, "--text=BD", "--output-file=%s" % subsetpath])
853        subsetfont = TTFont(subsetpath)
854        self.expect_ttx(
855            subsetfont,
856            self.getpath("expect_HVVAR.ttx"),
857            ["GlyphOrder", "HVAR", "VVAR", "avar", "fvar"],
858        )
859
860    def test_HVAR_VVAR_retain_gids(self):
861        fontpath = self.compile_font(self.getpath("TestHVVAR.ttx"), ".ttf")
862        subsetpath = self.temp_path(".ttf")
863        subset.main(
864            [fontpath, "--text=BD", "--retain-gids", "--output-file=%s" % subsetpath]
865        )
866        subsetfont = TTFont(subsetpath)
867        self.expect_ttx(
868            subsetfont,
869            self.getpath("expect_HVVAR_retain_gids.ttx"),
870            ["GlyphOrder", "HVAR", "VVAR", "avar", "fvar"],
871        )
872
873    def test_subset_flavor_woff(self):
874        fontpath = self.compile_font(self.getpath("TestTTF-Regular.ttx"), ".ttf")
875        woff_path = self.temp_path(".woff")
876
877        subset.main(
878            [
879                fontpath,
880                "*",
881                "--flavor=woff",
882                "--output-file=%s" % woff_path,
883            ]
884        )
885        woff = TTFont(woff_path)
886
887        assert woff.flavor == "woff"
888
889    def test_subset_flavor_woff2(self):
890        # skip if brotli is not importable, required for woff2
891        pytest.importorskip("brotli")
892
893        fontpath = self.compile_font(self.getpath("TestTTF-Regular.ttx"), ".ttf")
894        woff2_path = self.temp_path(".woff2")
895
896        subset.main(
897            [
898                fontpath,
899                "*",
900                "--flavor=woff2",
901                "--output-file=%s" % woff2_path,
902            ]
903        )
904        woff2 = TTFont(woff2_path)
905
906        assert woff2.flavor == "woff2"
907
908    def test_subset_flavor_none(self):
909        fontpath = self.compile_font(self.getpath("TestTTF-Regular.ttx"), ".ttf")
910        ttf_path = self.temp_path(".ttf")
911
912        subset.main(
913            [
914                fontpath,
915                "*",
916                "--output-file=%s" % ttf_path,
917            ]
918        )
919        ttf = TTFont(ttf_path)
920
921        assert ttf.flavor is None
922
923    def test_subset_context_subst_format_3(self):
924        # https://github.com/fonttools/fonttools/issues/1879
925        # Test font contains 'calt' feature with Format 3 ContextSubst lookup subtables
926        ttx = self.getpath("TestContextSubstFormat3.ttx")
927        fontpath = self.compile_font(ttx, ".ttf")
928        subsetpath = self.temp_path(".ttf")
929        subset.main([fontpath, "--unicodes=*", "--output-file=%s" % subsetpath])
930        subsetfont = TTFont(subsetpath)
931        # check all glyphs are kept via GSUB closure, no changes expected
932        self.expect_ttx(subsetfont, ttx)
933
934    def test_cmap_prune_format12(self):
935        fontpath = self.compile_font(self.getpath("CmapSubsetTest.ttx"), ".ttf")
936        subsetpath = self.temp_path(".ttf")
937        subset.main([fontpath, "--glyphs=a", "--output-file=%s" % subsetpath])
938        subsetfont = TTFont(subsetpath)
939        self.expect_ttx(subsetfont, self.getpath("CmapSubsetTest.subset.ttx"), ["cmap"])
940
941    @pytest.mark.parametrize("text, n", [("!", 1), ("#", 2)])
942    def test_GPOS_PairPos_Format2_useClass0(self, text, n):
943        # Check two things related to class 0 ('every other glyph'):
944        # 1) that it's reused for ClassDef1 when it becomes empty as the subset glyphset
945        #    is intersected with the table's Coverage
946        # 2) that it is never reused for ClassDef2 even when it happens to become empty
947        #    because of the subset glyphset. In this case, we don't keep a PairPosClass2
948        #    subtable if only ClassDef2's class0 survived subsetting.
949        # The test font (from Harfbuzz test suite) is constructed to trigger these two
950        # situations depending on the input subset --text.
951        # https://github.com/fonttools/fonttools/pull/2221
952        fontpath = self.compile_font(
953            self.getpath("GPOS_PairPos_Format2_PR_2221.ttx"), ".ttf"
954        )
955        subsetpath = self.temp_path(".ttf")
956
957        expected_ttx = self.getpath(
958            f"GPOS_PairPos_Format2_ClassDef{n}_useClass0.subset.ttx"
959        )
960        subset.main(
961            [
962                fontpath,
963                f"--text='{text}'",
964                "--layout-features+=test",
965                "--output-file=%s" % subsetpath,
966            ]
967        )
968        subsetfont = TTFont(subsetpath)
969        self.expect_ttx(subsetfont, expected_ttx, ["GPOS"])
970
971    def test_GPOS_SinglePos_prune_post_subset_no_value(self):
972        fontpath = self.compile_font(
973            self.getpath("GPOS_SinglePos_no_value_issue_2312.ttx"), ".ttf"
974        )
975        subsetpath = self.temp_path(".ttf")
976        subset.main([fontpath, "*", "--glyph-names", "--output-file=%s" % subsetpath])
977        subsetfont = TTFont(subsetpath)
978        self.expect_ttx(
979            subsetfont,
980            self.getpath("GPOS_SinglePos_no_value_issue_2312.subset.ttx"),
981            ["GlyphOrder", "GPOS"],
982        )
983
984    @pytest.mark.parametrize(
985        "installed, enabled, ok",
986        [
987            pytest.param(True, None, True, id="installed-auto-ok"),
988            pytest.param(True, None, False, id="installed-auto-fail"),
989            pytest.param(True, True, True, id="installed-enabled-ok"),
990            pytest.param(True, True, False, id="installed-enabled-fail"),
991            pytest.param(True, False, True, id="installed-disabled"),
992            pytest.param(False, True, True, id="not_installed-enabled"),
993            pytest.param(False, False, True, id="not_installed-disabled"),
994        ],
995    )
996    def test_harfbuzz_repacker(self, caplog, monkeypatch, installed, enabled, ok):
997        # Use a mock to test the pure-python serializer is used when uharfbuzz
998        # returns an error or is not installed
999        have_uharfbuzz = fontTools.ttLib.tables.otBase.have_uharfbuzz
1000        if installed:
1001            if not have_uharfbuzz:
1002                pytest.skip("uharfbuzz is not installed")
1003            if not ok:
1004                # pretend hb.repack/repack_with_tag return an error
1005                import uharfbuzz as hb
1006
1007                def mock_repack(data, obj_list):
1008                    raise hb.RepackerError("mocking")
1009
1010                monkeypatch.setattr(hb, "repack", mock_repack)
1011
1012                if hasattr(hb, "repack_with_tag"):  # uharfbuzz >= 0.30.0
1013
1014                    def mock_repack_with_tag(tag, data, obj_list):
1015                        raise hb.RepackerError("mocking")
1016
1017                    monkeypatch.setattr(hb, "repack_with_tag", mock_repack_with_tag)
1018        else:
1019            if have_uharfbuzz:
1020                # pretend uharfbuzz is not installed
1021                monkeypatch.setattr(
1022                    fontTools.ttLib.tables.otBase, "have_uharfbuzz", False
1023                )
1024
1025        fontpath = self.compile_font(self.getpath("harfbuzz_repacker.ttx"), ".otf")
1026        subsetpath = self.temp_path(".otf")
1027        args = [
1028            fontpath,
1029            "--unicodes=0x53a9",
1030            "--layout-features=*",
1031            f"--output-file={subsetpath}",
1032        ]
1033        if enabled is True:
1034            args.append("--harfbuzz-repacker")
1035        elif enabled is False:
1036            args.append("--no-harfbuzz-repacker")
1037        # elif enabled is None: ... is the default
1038
1039        if enabled is True and not installed:
1040            # raise if enabled but not installed
1041            with pytest.raises(ImportError, match="uharfbuzz"):
1042                subset.main(args)
1043            return
1044
1045        with caplog.at_level(logging.DEBUG, "fontTools.ttLib.tables.otBase"):
1046            subset.main(args)
1047
1048        subsetfont = TTFont(subsetpath)
1049        # both hb.repack and pure-python serializer compile to the same ttx
1050        self.expect_ttx(
1051            subsetfont, self.getpath("expect_harfbuzz_repacker.ttx"), ["GSUB"]
1052        )
1053
1054        if enabled or enabled is None:
1055            if installed:
1056                assert "serializing 'GSUB' with hb.repack" in caplog.text
1057
1058        if enabled is None and not installed:
1059            assert (
1060                "uharfbuzz not found, compiling 'GSUB' with pure-python serializer"
1061            ) in caplog.text
1062
1063        if enabled is False:
1064            assert (
1065                "hb.repack disabled, compiling 'GSUB' with pure-python serializer"
1066            ) in caplog.text
1067
1068        # test we emit a log.error if hb.repack fails (and we don't if successful)
1069        assert (
1070            (
1071                "hb.repack failed to serialize 'GSUB', attempting fonttools resolutions "
1072                "; the error message was: RepackerError: mocking"
1073            )
1074            in caplog.text
1075        ) ^ ok
1076
1077    def test_retain_east_asian_spacing_features(self):
1078        # This test font contains halt and vhal features, check that
1079        # they are retained by default after subsetting.
1080        ttx_path = self.getpath("NotoSansCJKjp-Regular.subset.ttx")
1081        ttx = pathlib.Path(ttx_path).read_text()
1082        assert 'FeatureTag value="halt"' in ttx
1083        assert 'FeatureTag value="vhal"' in ttx
1084
1085        fontpath = self.compile_font(ttx_path, ".otf")
1086        subsetpath = self.temp_path(".otf")
1087        subset.main(
1088            [
1089                fontpath,
1090                "--unicodes=*",
1091                "--output-file=%s" % subsetpath,
1092            ]
1093        )
1094        # subset output is the same as the input
1095        self.expect_ttx(TTFont(subsetpath), ttx_path)
1096
1097
1098@pytest.fixture
1099def featureVarsTestFont():
1100    fb = FontBuilder(unitsPerEm=100)
1101    fb.setupGlyphOrder([".notdef", "f", "f_f", "dollar", "dollar.rvrn"])
1102    fb.setupCharacterMap({ord("f"): "f", ord("$"): "dollar"})
1103    fb.setupNameTable({"familyName": "TestFeatureVars", "styleName": "Regular"})
1104    fb.setupPost()
1105    fb.setupFvar(axes=[("wght", 100, 400, 900, "Weight")], instances=[])
1106    fb.addOpenTypeFeatures(
1107        """\
1108        feature dlig {
1109            sub f f by f_f;
1110        } dlig;
1111    """
1112    )
1113    fb.addFeatureVariations(
1114        [([{"wght": (0.20886, 1.0)}], {"dollar": "dollar.rvrn"})], featureTag="rvrn"
1115    )
1116    buf = io.BytesIO()
1117    fb.save(buf)
1118    buf.seek(0)
1119
1120    return TTFont(buf)
1121
1122
1123def test_subset_feature_variations_keep_all(featureVarsTestFont):
1124    font = featureVarsTestFont
1125
1126    options = subset.Options()
1127    subsetter = subset.Subsetter(options)
1128    subsetter.populate(unicodes=[ord("f"), ord("$")])
1129    subsetter.subset(font)
1130
1131    featureTags = {r.FeatureTag for r in font["GSUB"].table.FeatureList.FeatureRecord}
1132    # 'dlig' is discretionary so it is dropped by default
1133    assert "dlig" not in featureTags
1134    assert "f_f" not in font.getGlyphOrder()
1135    # 'rvrn' is required so it is kept by default
1136    assert "rvrn" in featureTags
1137    assert "dollar.rvrn" in font.getGlyphOrder()
1138
1139
1140def test_subset_feature_variations_drop_all(featureVarsTestFont):
1141    font = featureVarsTestFont
1142
1143    options = subset.Options()
1144    options.layout_features.remove("rvrn")  # drop 'rvrn'
1145    subsetter = subset.Subsetter(options)
1146    subsetter.populate(unicodes=[ord("f"), ord("$")])
1147    subsetter.subset(font)
1148
1149    featureTags = {r.FeatureTag for r in font["GSUB"].table.FeatureList.FeatureRecord}
1150    glyphs = set(font.getGlyphOrder())
1151
1152    assert "rvrn" not in featureTags
1153    assert glyphs == {".notdef", "f", "dollar"}
1154    # all FeatureVariationRecords were dropped
1155    assert font["GSUB"].table.FeatureVariations is None
1156    assert font["GSUB"].table.Version == 0x00010000
1157
1158
1159# TODO test_subset_feature_variations_drop_from_end_empty_records
1160# https://github.com/fonttools/fonttools/issues/1881#issuecomment-619415044
1161
1162
1163@pytest.fixture
1164def singlepos2_font():
1165    fb = FontBuilder(unitsPerEm=1000)
1166    fb.setupGlyphOrder([".notdef", "a", "b", "c"])
1167    fb.setupCharacterMap({ord("a"): "a", ord("b"): "b", ord("c"): "c"})
1168    fb.setupNameTable({"familyName": "TestSingePosFormat", "styleName": "Regular"})
1169    fb.setupPost()
1170    fb.addOpenTypeFeatures(
1171        """
1172        feature kern {
1173            pos a -50;
1174            pos b -40;
1175            pos c -50;
1176        } kern;
1177    """
1178    )
1179
1180    buf = io.BytesIO()
1181    fb.save(buf)
1182    buf.seek(0)
1183
1184    return TTFont(buf)
1185
1186
1187def test_subset_single_pos_format(singlepos2_font):
1188    font = singlepos2_font
1189    # The input font has a SinglePos Format 2 subtable where each glyph has
1190    # different ValueRecords
1191    assert getXML(font["GPOS"].table.LookupList.Lookup[0].toXML, font) == [
1192        "<Lookup>",
1193        '  <LookupType value="1"/>',
1194        '  <LookupFlag value="0"/>',
1195        "  <!-- SubTableCount=1 -->",
1196        '  <SinglePos index="0" Format="2">',
1197        "    <Coverage>",
1198        '      <Glyph value="a"/>',
1199        '      <Glyph value="b"/>',
1200        '      <Glyph value="c"/>',
1201        "    </Coverage>",
1202        '    <ValueFormat value="4"/>',
1203        "    <!-- ValueCount=3 -->",
1204        '    <Value index="0" XAdvance="-50"/>',
1205        '    <Value index="1" XAdvance="-40"/>',
1206        '    <Value index="2" XAdvance="-50"/>',
1207        "  </SinglePos>",
1208        "</Lookup>",
1209    ]
1210
1211    options = subset.Options()
1212    subsetter = subset.Subsetter(options)
1213    subsetter.populate(unicodes=[ord("a"), ord("c")])
1214    subsetter.subset(font)
1215
1216    # All the subsetted glyphs from the original SinglePos Format2 subtable
1217    # now have the same ValueRecord, so we use a more compact Format 1 subtable.
1218    assert getXML(font["GPOS"].table.LookupList.Lookup[0].toXML, font) == [
1219        "<Lookup>",
1220        '  <LookupType value="1"/>',
1221        '  <LookupFlag value="0"/>',
1222        "  <!-- SubTableCount=1 -->",
1223        '  <SinglePos index="0" Format="1">',
1224        "    <Coverage>",
1225        '      <Glyph value="a"/>',
1226        '      <Glyph value="c"/>',
1227        "    </Coverage>",
1228        '    <ValueFormat value="4"/>',
1229        '    <Value XAdvance="-50"/>',
1230        "  </SinglePos>",
1231        "</Lookup>",
1232    ]
1233
1234
1235def test_subset_single_pos_format2_all_None(singlepos2_font):
1236    # https://github.com/fonttools/fonttools/issues/2602
1237    font = singlepos2_font
1238    gpos = font["GPOS"].table
1239    subtable = gpos.LookupList.Lookup[0].SubTable[0]
1240    assert subtable.Format == 2
1241    # Hack a SinglePosFormat2 with ValueFormat = 0; our own buildSinglePos
1242    # never makes these as a SinglePosFormat1 is more compact, but they can
1243    # be found in the wild.
1244    subtable.Value = [None] * subtable.ValueCount
1245    subtable.ValueFormat = 0
1246
1247    assert getXML(subtable.toXML, font) == [
1248        '<SinglePos Format="2">',
1249        "  <Coverage>",
1250        '    <Glyph value="a"/>',
1251        '    <Glyph value="b"/>',
1252        '    <Glyph value="c"/>',
1253        "  </Coverage>",
1254        '  <ValueFormat value="0"/>',
1255        "  <!-- ValueCount=3 -->",
1256        "</SinglePos>",
1257    ]
1258
1259    options = subset.Options()
1260    subsetter = subset.Subsetter(options)
1261    subsetter.populate(unicodes=[ord("a"), ord("c")])
1262    subsetter.subset(font)
1263
1264    # Check it was downgraded to Format1 after subsetting
1265    assert getXML(font["GPOS"].table.LookupList.Lookup[0].SubTable[0].toXML, font) == [
1266        '<SinglePos Format="1">',
1267        "  <Coverage>",
1268        '    <Glyph value="a"/>',
1269        '    <Glyph value="c"/>',
1270        "  </Coverage>",
1271        '  <ValueFormat value="0"/>',
1272        "</SinglePos>",
1273    ]
1274
1275
1276@pytest.fixture
1277def ttf_path(tmp_path):
1278    # $(dirname $0)/../ttLib/data
1279    ttLib_data = pathlib.Path(__file__).parent.parent / "ttLib" / "data"
1280    font = TTFont()
1281    font.importXML(ttLib_data / "TestTTF-Regular.ttx")
1282    font_path = tmp_path / "TestTTF-Regular.ttf"
1283    font.save(font_path)
1284    return font_path
1285
1286
1287def test_subset_empty_glyf(tmp_path, ttf_path):
1288    subset_path = tmp_path / (ttf_path.name + ".subset")
1289    # only keep empty .notdef and space glyph, resulting in an empty glyf table
1290    subset.main(
1291        [
1292            str(ttf_path),
1293            "--no-notdef-outline",
1294            "--glyph-names",
1295            f"--output-file={subset_path}",
1296            "--glyphs=.notdef space",
1297        ]
1298    )
1299    subset_font = TTFont(subset_path)
1300
1301    assert subset_font.getGlyphOrder() == [".notdef", "space"]
1302    assert subset_font.reader["glyf"] == b"\x00"
1303
1304    glyf = subset_font["glyf"]
1305    assert all(glyf[g].numberOfContours == 0 for g in subset_font.getGlyphOrder())
1306
1307    loca = subset_font["loca"]
1308    assert all(loc == 0 for loc in loca)
1309
1310
1311@pytest.fixture
1312def colrv1_path(tmp_path):
1313    base_glyph_names = ["uni%04X" % i for i in range(0xE000, 0xE000 + 10)]
1314    layer_glyph_names = ["glyph%05d" % i for i in range(10, 20)]
1315    glyph_order = [".notdef"] + base_glyph_names + layer_glyph_names
1316
1317    pen = TTGlyphPen(glyphSet=None)
1318    pen.moveTo((0, 0))
1319    pen.lineTo((0, 500))
1320    pen.lineTo((500, 500))
1321    pen.lineTo((500, 0))
1322    pen.closePath()
1323    glyph = pen.glyph()
1324    glyphs = {g: glyph for g in glyph_order}
1325
1326    fb = FontBuilder(unitsPerEm=1024, isTTF=True)
1327    fb.setupGlyphOrder(glyph_order)
1328    fb.setupCharacterMap({int(name[3:], 16): name for name in base_glyph_names})
1329    fb.setupGlyf(glyphs)
1330    fb.setupHorizontalMetrics({g: (500, 0) for g in glyph_order})
1331    fb.setupHorizontalHeader()
1332    fb.setupOS2()
1333    fb.setupPost()
1334    fb.setupNameTable({"familyName": "TestCOLRv1", "styleName": "Regular"})
1335
1336    fb.setupCOLR(
1337        {
1338            "uniE000": (
1339                ot.PaintFormat.PaintColrLayers,
1340                [
1341                    {
1342                        "Format": ot.PaintFormat.PaintGlyph,
1343                        "Paint": (ot.PaintFormat.PaintSolid, 0),
1344                        "Glyph": "glyph00010",
1345                    },
1346                    {
1347                        "Format": ot.PaintFormat.PaintGlyph,
1348                        "Paint": (ot.PaintFormat.PaintSolid, 2, 0.3),
1349                        "Glyph": "glyph00011",
1350                    },
1351                ],
1352            ),
1353            "uniE001": (
1354                ot.PaintFormat.PaintColrLayers,
1355                [
1356                    {
1357                        "Format": ot.PaintFormat.PaintTransform,
1358                        "Paint": {
1359                            "Format": ot.PaintFormat.PaintGlyph,
1360                            "Paint": {
1361                                "Format": ot.PaintFormat.PaintRadialGradient,
1362                                "x0": 250,
1363                                "y0": 250,
1364                                "r0": 250,
1365                                "x1": 200,
1366                                "y1": 200,
1367                                "r1": 0,
1368                                "ColorLine": {
1369                                    "ColorStop": [(0.0, 1), (1.0, 2)],
1370                                    "Extend": "repeat",
1371                                },
1372                            },
1373                            "Glyph": "glyph00012",
1374                        },
1375                        "Transform": (0.7071, 0.7071, -0.7071, 0.7071, 0, 0),
1376                    },
1377                    {
1378                        "Format": ot.PaintFormat.PaintGlyph,
1379                        "Paint": (ot.PaintFormat.PaintSolid, 1, 0.5),
1380                        "Glyph": "glyph00013",
1381                    },
1382                ],
1383            ),
1384            "uniE002": (
1385                ot.PaintFormat.PaintColrLayers,
1386                [
1387                    {
1388                        "Format": ot.PaintFormat.PaintGlyph,
1389                        "Paint": {
1390                            "Format": ot.PaintFormat.PaintLinearGradient,
1391                            "x0": 0,
1392                            "y0": 0,
1393                            "x1": 500,
1394                            "y1": 500,
1395                            "x2": -500,
1396                            "y2": 500,
1397                            "ColorLine": {"ColorStop": [(0.0, 1), (1.0, 2)]},
1398                        },
1399                        "Glyph": "glyph00014",
1400                    },
1401                    {
1402                        "Format": ot.PaintFormat.PaintTransform,
1403                        "Paint": {
1404                            "Format": ot.PaintFormat.PaintGlyph,
1405                            "Paint": (ot.PaintFormat.PaintSolid, 1),
1406                            "Glyph": "glyph00015",
1407                        },
1408                        "Transform": (1, 0, 0, 1, 400, 400),
1409                    },
1410                ],
1411            ),
1412            "uniE003": {
1413                "Format": ot.PaintFormat.PaintRotateAroundCenter,
1414                "Paint": {
1415                    "Format": ot.PaintFormat.PaintColrGlyph,
1416                    "Glyph": "uniE001",
1417                },
1418                "angle": 45,
1419                "centerX": 250,
1420                "centerY": 250,
1421            },
1422            "uniE004": [
1423                ("glyph00016", 1),
1424                ("glyph00017", 0xFFFF),  # special palette index for foreground text
1425                ("glyph00018", 2),
1426            ],
1427        },
1428        clipBoxes={
1429            "uniE000": (0, 0, 200, 300),
1430            "uniE001": (0, 0, 500, 500),
1431            "uniE002": (-50, -50, 400, 400),
1432            "uniE003": (-50, -50, 400, 400),
1433        },
1434    )
1435    fb.setupCPAL(
1436        [
1437            [
1438                (1.0, 0.0, 0.0, 1.0),  # red
1439                (0.0, 1.0, 0.0, 1.0),  # green
1440                (0.0, 0.0, 1.0, 1.0),  # blue
1441            ],
1442        ],
1443    )
1444
1445    output_path = tmp_path / "TestCOLRv1.ttf"
1446    fb.save(output_path)
1447
1448    return output_path
1449
1450
1451@pytest.fixture
1452def colrv1_cpalv1_path(colrv1_path):
1453    # upgrade CPAL from v0 to v1 by adding labels
1454    font = TTFont(colrv1_path)
1455    fb = FontBuilder(font=font)
1456    fb.setupCPAL(
1457        [
1458            [
1459                (1.0, 0.0, 0.0, 1.0),  # red
1460                (0.0, 1.0, 0.0, 1.0),  # green
1461                (0.0, 0.0, 1.0, 1.0),  # blue
1462            ],
1463        ],
1464        paletteLabels=["test palette"],
1465        paletteEntryLabels=["first color", "second color", "third color"],
1466    )
1467
1468    output_path = colrv1_path.parent / "TestCOLRv1CPALv1.ttf"
1469    fb.save(output_path)
1470
1471    return output_path
1472
1473
1474@pytest.fixture
1475def colrv1_cpalv1_share_nameID_path(colrv1_path):
1476    font = TTFont(colrv1_path)
1477    fb = FontBuilder(font=font)
1478    fb.setupCPAL(
1479        [
1480            [
1481                (1.0, 0.0, 0.0, 1.0),  # red
1482                (0.0, 1.0, 0.0, 1.0),  # green
1483                (0.0, 0.0, 1.0, 1.0),  # blue
1484            ],
1485        ],
1486        paletteLabels=["test palette"],
1487        paletteEntryLabels=["first color", "second color", "third color"],
1488    )
1489
1490    # Set the name ID of the first color to use nameID 1 = familyName = "TestCOLRv1"
1491    fb.font["CPAL"].paletteEntryLabels[0] = 1
1492
1493    output_path = colrv1_path.parent / "TestCOLRv1CPALv1.ttf"
1494    fb.save(output_path)
1495
1496    return output_path
1497
1498
1499def test_subset_COLRv1_and_CPAL(colrv1_path):
1500    subset_path = colrv1_path.parent / (colrv1_path.name + ".subset")
1501
1502    subset.main(
1503        [
1504            str(colrv1_path),
1505            "--glyph-names",
1506            f"--output-file={subset_path}",
1507            "--unicodes=E002,E003,E004",
1508        ]
1509    )
1510    subset_font = TTFont(subset_path)
1511
1512    glyph_set = set(subset_font.getGlyphOrder())
1513
1514    # uniE000 and its children are excluded from subset
1515    assert "uniE000" not in glyph_set
1516    assert "glyph00010" not in glyph_set
1517    assert "glyph00011" not in glyph_set
1518
1519    # uniE001 and children are pulled in indirectly as PaintColrGlyph by uniE003
1520    assert "uniE001" in glyph_set
1521    assert "glyph00012" in glyph_set
1522    assert "glyph00013" in glyph_set
1523
1524    assert "uniE002" in glyph_set
1525    assert "glyph00014" in glyph_set
1526    assert "glyph00015" in glyph_set
1527
1528    assert "uniE003" in glyph_set
1529
1530    assert "uniE004" in glyph_set
1531    assert "glyph00016" in glyph_set
1532    assert "glyph00017" in glyph_set
1533    assert "glyph00018" in glyph_set
1534
1535    assert "COLR" in subset_font
1536    colr = subset_font["COLR"].table
1537    assert colr.Version == 1
1538    assert len(colr.BaseGlyphRecordArray.BaseGlyphRecord) == 1
1539    assert len(colr.BaseGlyphList.BaseGlyphPaintRecord) == 3  # was 4
1540
1541    base = colr.BaseGlyphList.BaseGlyphPaintRecord[0]
1542    assert base.BaseGlyph == "uniE001"
1543    layers = colr.LayerList.Paint[
1544        base.Paint.FirstLayerIndex : base.Paint.FirstLayerIndex + base.Paint.NumLayers
1545    ]
1546    assert len(layers) == 2
1547    # check v1 palette indices were remapped
1548    assert layers[0].Paint.Paint.ColorLine.ColorStop[0].PaletteIndex == 0
1549    assert layers[0].Paint.Paint.ColorLine.ColorStop[1].PaletteIndex == 1
1550    assert layers[1].Paint.PaletteIndex == 0
1551
1552    baseRecV0 = colr.BaseGlyphRecordArray.BaseGlyphRecord[0]
1553    assert baseRecV0.BaseGlyph == "uniE004"
1554    layersV0 = colr.LayerRecordArray.LayerRecord
1555    assert len(layersV0) == 3
1556    # check v0 palette indices were remapped (except for 0xFFFF)
1557    assert layersV0[0].PaletteIndex == 0
1558    assert layersV0[1].PaletteIndex == 0xFFFF
1559    assert layersV0[2].PaletteIndex == 1
1560
1561    clipBoxes = colr.ClipList.clips
1562    assert {"uniE001", "uniE002", "uniE003"} == set(clipBoxes)
1563    assert clipBoxes["uniE002"] == clipBoxes["uniE003"]
1564
1565    assert "CPAL" in subset_font
1566    cpal = subset_font["CPAL"]
1567    assert [
1568        tuple(v / 255 for v in (c.red, c.green, c.blue, c.alpha))
1569        for c in cpal.palettes[0]
1570    ] == [
1571        # the first color 'red' was pruned
1572        (0.0, 1.0, 0.0, 1.0),  # green
1573        (0.0, 0.0, 1.0, 1.0),  # blue
1574    ]
1575
1576
1577def test_subset_COLRv1_and_CPALv1(colrv1_cpalv1_path):
1578    subset_path = colrv1_cpalv1_path.parent / (colrv1_cpalv1_path.name + ".subset")
1579
1580    subset.main(
1581        [
1582            str(colrv1_cpalv1_path),
1583            "--glyph-names",
1584            f"--output-file={subset_path}",
1585            "--unicodes=E002,E003,E004",
1586        ]
1587    )
1588    subset_font = TTFont(subset_path)
1589
1590    assert "CPAL" in subset_font
1591    cpal = subset_font["CPAL"]
1592    name_table = subset_font["name"]
1593    assert [
1594        name_table.getDebugName(name_id) for name_id in cpal.paletteEntryLabels
1595    ] == [
1596        # "first color",  # The first color was pruned
1597        "second color",
1598        "third color",
1599    ]
1600    # check that the "first color" name is dropped from name table
1601    font = TTFont(colrv1_cpalv1_path)
1602
1603    first_color_nameID = None
1604    for n in font["name"].names:
1605        if n.toUnicode() == "first color":
1606            first_color_nameID = n.nameID
1607            break
1608    assert first_color_nameID is not None
1609    assert all(n.nameID != first_color_nameID for n in name_table.names)
1610
1611
1612def test_subset_COLRv1_and_CPALv1_keep_nameID(colrv1_cpalv1_path):
1613    subset_path = colrv1_cpalv1_path.parent / (colrv1_cpalv1_path.name + ".subset")
1614
1615    # figure out the name ID of first color so we can keep it
1616    font = TTFont(colrv1_cpalv1_path)
1617
1618    first_color_nameID = None
1619    for n in font["name"].names:
1620        if n.toUnicode() == "first color":
1621            first_color_nameID = n.nameID
1622            break
1623    assert first_color_nameID is not None
1624
1625    subset.main(
1626        [
1627            str(colrv1_cpalv1_path),
1628            "--glyph-names",
1629            f"--output-file={subset_path}",
1630            "--unicodes=E002,E003,E004",
1631            f"--name-IDs={first_color_nameID}",
1632        ]
1633    )
1634    subset_font = TTFont(subset_path)
1635
1636    assert "CPAL" in subset_font
1637    cpal = subset_font["CPAL"]
1638    name_table = subset_font["name"]
1639    assert [
1640        name_table.getDebugName(name_id) for name_id in cpal.paletteEntryLabels
1641    ] == [
1642        # "first color",  # The first color was pruned
1643        "second color",
1644        "third color",
1645    ]
1646
1647    # Check that the name ID is kept
1648    assert any(n.nameID == first_color_nameID for n in name_table.names)
1649
1650
1651def test_subset_COLRv1_and_CPALv1_share_nameID(colrv1_cpalv1_share_nameID_path):
1652    subset_path = colrv1_cpalv1_share_nameID_path.parent / (
1653        colrv1_cpalv1_share_nameID_path.name + ".subset"
1654    )
1655
1656    subset.main(
1657        [
1658            str(colrv1_cpalv1_share_nameID_path),
1659            "--glyph-names",
1660            f"--output-file={subset_path}",
1661            "--unicodes=E002,E003,E004",
1662        ]
1663    )
1664    subset_font = TTFont(subset_path)
1665
1666    assert "CPAL" in subset_font
1667    cpal = subset_font["CPAL"]
1668    name_table = subset_font["name"]
1669    assert [
1670        name_table.getDebugName(name_id) for name_id in cpal.paletteEntryLabels
1671    ] == [
1672        # "first color",  # The first color was pruned
1673        "second color",
1674        "third color",
1675    ]
1676
1677    # Check that the name ID 1 is kept
1678    assert any(n.nameID == 1 for n in name_table.names)
1679
1680
1681def test_subset_COLRv1_and_CPAL_drop_empty(colrv1_path):
1682    subset_path = colrv1_path.parent / (colrv1_path.name + ".subset")
1683
1684    subset.main(
1685        [
1686            str(colrv1_path),
1687            "--glyph-names",
1688            f"--output-file={subset_path}",
1689            "--glyphs=glyph00010",
1690        ]
1691    )
1692    subset_font = TTFont(subset_path)
1693
1694    glyph_set = set(subset_font.getGlyphOrder())
1695
1696    assert "glyph00010" in glyph_set
1697    assert "uniE000" not in glyph_set
1698
1699    assert "COLR" not in subset_font
1700    assert "CPAL" not in subset_font
1701
1702
1703def test_subset_COLRv1_downgrade_version(colrv1_path):
1704    subset_path = colrv1_path.parent / (colrv1_path.name + ".subset")
1705
1706    subset.main(
1707        [
1708            str(colrv1_path),
1709            "--glyph-names",
1710            f"--output-file={subset_path}",
1711            "--unicodes=E004",
1712        ]
1713    )
1714    subset_font = TTFont(subset_path)
1715
1716    assert set(subset_font.getGlyphOrder()) == {
1717        ".notdef",
1718        "uniE004",
1719        "glyph00016",
1720        "glyph00017",
1721        "glyph00018",
1722    }
1723
1724    assert "COLR" in subset_font
1725    assert subset_font["COLR"].version == 0
1726
1727
1728def test_subset_COLRv1_drop_all_v0_glyphs(colrv1_path):
1729    subset_path = colrv1_path.parent / (colrv1_path.name + ".subset")
1730
1731    subset.main(
1732        [
1733            str(colrv1_path),
1734            "--glyph-names",
1735            f"--output-file={subset_path}",
1736            "--unicodes=E003",
1737        ]
1738    )
1739    subset_font = TTFont(subset_path)
1740
1741    assert set(subset_font.getGlyphOrder()) == {
1742        ".notdef",
1743        "uniE001",
1744        "uniE003",
1745        "glyph00012",
1746        "glyph00013",
1747    }
1748
1749    assert "COLR" in subset_font
1750    colr = subset_font["COLR"]
1751    assert colr.version == 1
1752    assert colr.table.BaseGlyphRecordCount == 0
1753    assert colr.table.BaseGlyphRecordArray is None
1754    assert colr.table.LayerRecordArray is None
1755    assert colr.table.LayerRecordCount is 0
1756
1757
1758def test_subset_COLRv1_no_ClipList(colrv1_path):
1759    font = TTFont(colrv1_path)
1760    font["COLR"].table.ClipList = None  # empty ClipList
1761    font.save(colrv1_path)
1762
1763    subset_path = colrv1_path.parent / (colrv1_path.name + ".subset")
1764    subset.main(
1765        [
1766            str(colrv1_path),
1767            f"--output-file={subset_path}",
1768            "--unicodes=*",
1769        ]
1770    )
1771    subset_font = TTFont(subset_path)
1772    assert subset_font["COLR"].table.ClipList is None
1773
1774
1775def test_subset_keep_size_drop_empty_stylistic_set():
1776    fb = FontBuilder(unitsPerEm=1000, isTTF=True)
1777    glyph_order = [".notdef", "a", "b", "b.ss01"]
1778    fb.setupGlyphOrder(glyph_order)
1779    fb.setupGlyf({g: TTGlyphPen(None).glyph() for g in glyph_order})
1780    fb.setupCharacterMap({ord("a"): "a", ord("b"): "b"})
1781    fb.setupHorizontalMetrics({g: (500, 0) for g in glyph_order})
1782    fb.setupHorizontalHeader()
1783    fb.setupOS2()
1784    fb.setupPost()
1785    fb.setupNameTable({"familyName": "TestKeepSizeFeature", "styleName": "Regular"})
1786    fb.addOpenTypeFeatures(
1787        """
1788        feature size {
1789          parameters 10.0 0;
1790        } size;
1791        feature ss01 {
1792          featureNames {
1793            name "Alternate b";
1794          };
1795          sub b by b.ss01;
1796        } ss01;
1797    """
1798    )
1799
1800    buf = io.BytesIO()
1801    fb.save(buf)
1802    buf.seek(0)
1803
1804    font = TTFont(buf)
1805
1806    gpos_features = font["GPOS"].table.FeatureList.FeatureRecord
1807    assert gpos_features[0].FeatureTag == "size"
1808    assert isinstance(gpos_features[0].Feature.FeatureParams, ot.FeatureParamsSize)
1809    assert gpos_features[0].Feature.LookupCount == 0
1810    gsub_features = font["GSUB"].table.FeatureList.FeatureRecord
1811    assert gsub_features[0].FeatureTag == "ss01"
1812    assert isinstance(
1813        gsub_features[0].Feature.FeatureParams, ot.FeatureParamsStylisticSet
1814    )
1815
1816    options = subset.Options(layout_features=["*"])
1817    subsetter = subset.Subsetter(options)
1818    subsetter.populate(unicodes=[ord("a")])
1819    subsetter.subset(font)
1820
1821    # empty size feature was kept
1822    gpos_features = font["GPOS"].table.FeatureList.FeatureRecord
1823    assert gpos_features[0].FeatureTag == "size"
1824    assert isinstance(gpos_features[0].Feature.FeatureParams, ot.FeatureParamsSize)
1825    assert gpos_features[0].Feature.LookupCount == 0
1826    # empty ss01 feature was dropped
1827    assert font["GSUB"].table.FeatureList.FeatureCount == 0
1828
1829
1830@pytest.mark.skipif(etree is not None, reason="lxml is installed")
1831def test_subset_svg_missing_lxml(ttf_path):
1832    # add dummy SVG table and confirm we raise ImportError upon trying to subset it
1833    font = TTFont(ttf_path)
1834    font["SVG "] = newTable("SVG ")
1835    font["SVG "].docList = [('<svg><g id="glyph1"/></svg>', 1, 1)]
1836    font.save(ttf_path)
1837
1838    with pytest.raises(ImportError):
1839        subset.main([str(ttf_path), "--gids=0,1"])
1840
1841
1842def test_subset_COLR_glyph_closure(tmp_path):
1843    # https://github.com/fonttools/fonttools/issues/2461
1844    font = TTFont()
1845    ttx = pathlib.Path(__file__).parent / "data" / "BungeeColor-Regular.ttx"
1846    font.importXML(ttx)
1847
1848    color_layers = font["COLR"].ColorLayers
1849    assert ".notdef" in color_layers
1850    assert "Agrave" in color_layers
1851    assert "grave" in color_layers
1852
1853    font_path = tmp_path / "BungeeColor-Regular.ttf"
1854    subset_path = font_path.with_suffix(".subset.ttf)")
1855    font.save(font_path)
1856
1857    subset.main(
1858        [
1859            str(font_path),
1860            "--glyph-names",
1861            f"--output-file={subset_path}",
1862            "--glyphs=Agrave",
1863        ]
1864    )
1865    subset_font = TTFont(subset_path)
1866
1867    glyph_order = subset_font.getGlyphOrder()
1868
1869    assert glyph_order == [
1870        ".notdef",  # '.notdef' is always included automatically
1871        "A",
1872        "grave",
1873        "Agrave",
1874        ".notdef.alt001",
1875        ".notdef.alt002",
1876        "A.alt002",
1877        "Agrave.alt001",
1878        "Agrave.alt002",
1879        "grave.alt002",
1880    ]
1881
1882    color_layers = subset_font["COLR"].ColorLayers
1883    assert ".notdef" in color_layers
1884    assert "Agrave" in color_layers
1885    # Agrave 'glyf' uses grave. It should be retained in 'glyf' but NOT in
1886    # COLR when we subset down to Agrave.
1887    assert "grave" not in color_layers
1888
1889
1890def test_subset_recalc_xAvgCharWidth(ttf_path):
1891    # Note that the font in in the *ttLib*/data/TestTTF-Regular.ttx file,
1892    # not this subset/data folder.
1893    font = TTFont(ttf_path)
1894    xAvgCharWidth_before = font["OS/2"].xAvgCharWidth
1895
1896    subset_path = ttf_path.with_suffix(".subset.ttf")
1897    subset.main(
1898        [
1899            str(ttf_path),
1900            f"--output-file={subset_path}",
1901            # Keep only the ellipsis, which is very wide, that ought to bump up the average
1902            "--glyphs=ellipsis",
1903            "--recalc-average-width",
1904            "--no-prune-unicode-ranges",
1905        ]
1906    )
1907    subset_font = TTFont(subset_path)
1908    xAvgCharWidth_after = subset_font["OS/2"].xAvgCharWidth
1909
1910    # Check that the value gets updated
1911    assert xAvgCharWidth_after != xAvgCharWidth_before
1912
1913    # Check that the value gets updated to the actual new value
1914    subset_font["OS/2"].recalcAvgCharWidth(subset_font)
1915    assert xAvgCharWidth_after == subset_font["OS/2"].xAvgCharWidth
1916
1917
1918if __name__ == "__main__":
1919    sys.exit(unittest.main())
1920
1921
1922def test_subset_prune_gdef_markglyphsetsdef():
1923    # GDEF_MarkGlyphSetsDef
1924    fb = FontBuilder(unitsPerEm=1000, isTTF=True)
1925    glyph_order = [
1926        ".notdef",
1927        "A",
1928        "Aacute",
1929        "Acircumflex",
1930        "Adieresis",
1931        "a",
1932        "aacute",
1933        "acircumflex",
1934        "adieresis",
1935        "dieresiscomb",
1936        "acutecomb",
1937        "circumflexcomb",
1938    ]
1939    fb.setupGlyphOrder(glyph_order)
1940    fb.setupGlyf({g: TTGlyphPen(None).glyph() for g in glyph_order})
1941    fb.setupHorizontalMetrics({g: (500, 0) for g in glyph_order})
1942    fb.setupHorizontalHeader()
1943    fb.setupPost()
1944    fb.setupNameTable(
1945        {"familyName": "TestGDEFMarkGlyphSetsDef", "styleName": "Regular"}
1946    )
1947    fb.addOpenTypeFeatures(
1948        """
1949        feature ccmp {
1950            lookup ccmp_1 {
1951                lookupflag UseMarkFilteringSet [acutecomb];
1952                sub a acutecomb by aacute;
1953                sub A acutecomb by Aacute;
1954            } ccmp_1;
1955            lookup ccmp_2 {
1956                lookupflag UseMarkFilteringSet [circumflexcomb];
1957                sub a circumflexcomb by acircumflex;
1958                sub A circumflexcomb by Acircumflex;
1959            } ccmp_2;
1960            lookup ccmp_3 {
1961                lookupflag UseMarkFilteringSet [dieresiscomb];
1962                sub a dieresiscomb by adieresis;
1963                sub A dieresiscomb by Adieresis;
1964                sub A acutecomb by Aacute;
1965            } ccmp_3;
1966        } ccmp;
1967    """
1968    )
1969
1970    buf = io.BytesIO()
1971    fb.save(buf)
1972    buf.seek(0)
1973
1974    font = TTFont(buf)
1975
1976    features = font["GSUB"].table.FeatureList.FeatureRecord
1977    assert features[0].FeatureTag == "ccmp"
1978    lookups = font["GSUB"].table.LookupList.Lookup
1979    assert lookups[0].LookupFlag == 16
1980    assert lookups[0].MarkFilteringSet == 0
1981    assert lookups[1].LookupFlag == 16
1982    assert lookups[1].MarkFilteringSet == 1
1983    assert lookups[2].LookupFlag == 16
1984    assert lookups[2].MarkFilteringSet == 2
1985    marksets = font["GDEF"].table.MarkGlyphSetsDef.Coverage
1986    assert marksets[0].glyphs == ["acutecomb"]
1987    assert marksets[1].glyphs == ["circumflexcomb"]
1988    assert marksets[2].glyphs == ["dieresiscomb"]
1989
1990    options = subset.Options(layout_features=["*"])
1991    subsetter = subset.Subsetter(options)
1992    subsetter.populate(glyphs=["A", "a", "acutecomb", "dieresiscomb"])
1993    subsetter.subset(font)
1994
1995    features = font["GSUB"].table.FeatureList.FeatureRecord
1996    assert features[0].FeatureTag == "ccmp"
1997    lookups = font["GSUB"].table.LookupList.Lookup
1998    assert lookups[0].LookupFlag == 16
1999    assert lookups[0].MarkFilteringSet == 0
2000    assert lookups[1].LookupFlag == 16
2001    assert lookups[1].MarkFilteringSet == 1
2002    marksets = font["GDEF"].table.MarkGlyphSetsDef.Coverage
2003    assert marksets[0].glyphs == ["acutecomb"]
2004    assert marksets[1].glyphs == ["dieresiscomb"]
2005
2006    buf = io.BytesIO()
2007    fb.save(buf)
2008    buf.seek(0)
2009
2010    font = TTFont(buf)
2011
2012    options = subset.Options(layout_features=["*"], layout_closure=False)
2013    subsetter = subset.Subsetter(options)
2014    subsetter.populate(glyphs=["A", "acutecomb", "Aacute"])
2015    subsetter.subset(font)
2016
2017    features = font["GSUB"].table.FeatureList.FeatureRecord
2018    assert features[0].FeatureTag == "ccmp"
2019    lookups = font["GSUB"].table.LookupList.Lookup
2020    assert lookups[0].LookupFlag == 16
2021    assert lookups[0].MarkFilteringSet == 0
2022    assert lookups[1].LookupFlag == 0
2023    assert lookups[1].MarkFilteringSet == None
2024    marksets = font["GDEF"].table.MarkGlyphSetsDef.Coverage
2025    assert marksets[0].glyphs == ["acutecomb"]
2026