xref: /aosp_15_r20/external/fonttools/Tests/varLib/varLib_test.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
1from fontTools.colorLib.builder import buildCOLR
2from fontTools.ttLib import TTFont, newTable
3from fontTools.ttLib.tables import otTables as ot
4from fontTools.varLib import (
5    build,
6    build_many,
7    load_designspace,
8    _add_COLR,
9    addGSUBFeatureVariations,
10)
11from fontTools.varLib.errors import VarLibValidationError
12import fontTools.varLib.errors as varLibErrors
13from fontTools.varLib.models import VariationModel
14from fontTools.varLib.mutator import instantiateVariableFont
15from fontTools.varLib import main as varLib_main, load_masters
16from fontTools.varLib import set_default_weight_width_slant
17from fontTools.designspaceLib import (
18    DesignSpaceDocumentError,
19    DesignSpaceDocument,
20    SourceDescriptor,
21)
22from fontTools.feaLib.builder import addOpenTypeFeaturesFromString
23import difflib
24from copy import deepcopy
25from io import BytesIO
26import os
27import shutil
28import sys
29import tempfile
30import unittest
31import pytest
32
33
34def reload_font(font):
35    """(De)serialize to get final binary layout."""
36    buf = BytesIO()
37    font.save(buf)
38    # Close the font to release filesystem resources so that on Windows the tearDown
39    # method can successfully remove the temporary directory created during setUp.
40    font.close()
41    buf.seek(0)
42    return TTFont(buf)
43
44
45class BuildTest(unittest.TestCase):
46    def __init__(self, methodName):
47        unittest.TestCase.__init__(self, methodName)
48        # Python 3 renamed assertRaisesRegexp to assertRaisesRegex,
49        # and fires deprecation warnings if a program uses the old name.
50        if not hasattr(self, "assertRaisesRegex"):
51            self.assertRaisesRegex = self.assertRaisesRegexp
52
53    def setUp(self):
54        self.tempdir = None
55        self.num_tempfiles = 0
56
57    def tearDown(self):
58        if self.tempdir:
59            shutil.rmtree(self.tempdir)
60
61    def get_test_input(self, test_file_or_folder, copy=False):
62        parent_dir = os.path.dirname(__file__)
63        path = os.path.join(parent_dir, "data", test_file_or_folder)
64        if copy:
65            copied_path = os.path.join(self.tempdir, test_file_or_folder)
66            shutil.copy2(path, copied_path)
67            return copied_path
68        else:
69            return path
70
71    @staticmethod
72    def get_test_output(test_file_or_folder):
73        path, _ = os.path.split(__file__)
74        return os.path.join(path, "data", "test_results", test_file_or_folder)
75
76    @staticmethod
77    def get_file_list(folder, suffix, prefix=""):
78        all_files = os.listdir(folder)
79        file_list = []
80        for p in all_files:
81            if p.startswith(prefix) and p.endswith(suffix):
82                file_list.append(os.path.abspath(os.path.join(folder, p)))
83        return file_list
84
85    def temp_path(self, suffix):
86        self.temp_dir()
87        self.num_tempfiles += 1
88        return os.path.join(self.tempdir, "tmp%d%s" % (self.num_tempfiles, suffix))
89
90    def temp_dir(self):
91        if not self.tempdir:
92            self.tempdir = tempfile.mkdtemp()
93
94    def read_ttx(self, path):
95        lines = []
96        with open(path, "r", encoding="utf-8") as ttx:
97            for line in ttx.readlines():
98                # Elide ttFont attributes because ttLibVersion may change.
99                if line.startswith("<ttFont "):
100                    lines.append("<ttFont>\n")
101                else:
102                    lines.append(line.rstrip() + "\n")
103        return lines
104
105    def expect_ttx(self, font, expected_ttx, tables):
106        path = self.temp_path(suffix=".ttx")
107        font.saveXML(path, tables=tables)
108        actual = self.read_ttx(path)
109        expected = self.read_ttx(expected_ttx)
110        if actual != expected:
111            for line in difflib.unified_diff(
112                expected, actual, fromfile=expected_ttx, tofile=path
113            ):
114                sys.stdout.write(line)
115            self.fail("TTX output is different from expected")
116
117    def check_ttx_dump(self, font, expected_ttx, tables, suffix):
118        """Ensure the TTX dump is the same after saving and reloading the font."""
119        path = self.temp_path(suffix=suffix)
120        font.save(path)
121        self.expect_ttx(TTFont(path), expected_ttx, tables)
122
123    def compile_font(self, path, suffix, temp_dir):
124        ttx_filename = os.path.basename(path)
125        savepath = os.path.join(temp_dir, ttx_filename.replace(".ttx", suffix))
126        font = TTFont(recalcBBoxes=False, recalcTimestamp=False)
127        font.importXML(path)
128        font.save(savepath, reorderTables=None)
129        return font, savepath
130
131    def _run_varlib_build_test(
132        self,
133        designspace_name,
134        font_name,
135        tables,
136        expected_ttx_name,
137        save_before_dump=False,
138        post_process_master=None,
139    ):
140        suffix = ".ttf"
141        ds_path = self.get_test_input(designspace_name + ".designspace")
142        ufo_dir = self.get_test_input("master_ufo")
143        ttx_dir = self.get_test_input("master_ttx_interpolatable_ttf")
144
145        self.temp_dir()
146        ttx_paths = self.get_file_list(ttx_dir, ".ttx", font_name + "-")
147        for path in ttx_paths:
148            font, savepath = self.compile_font(path, suffix, self.tempdir)
149            if post_process_master is not None:
150                post_process_master(font, savepath)
151
152        finder = lambda s: s.replace(ufo_dir, self.tempdir).replace(".ufo", suffix)
153        varfont, model, _ = build(ds_path, finder)
154
155        if save_before_dump:
156            # some data (e.g. counts printed in TTX inline comments) is only
157            # calculated at compile time, so before we can compare the TTX
158            # dumps we need to save to a temporary stream, and realod the font
159            varfont = reload_font(varfont)
160
161        expected_ttx_path = self.get_test_output(expected_ttx_name + ".ttx")
162        self.expect_ttx(varfont, expected_ttx_path, tables)
163        self.check_ttx_dump(varfont, expected_ttx_path, tables, suffix)
164
165    # -----
166    # Tests
167    # -----
168
169    def test_varlib_build_ttf(self):
170        """Designspace file contains <axes> element."""
171        self._run_varlib_build_test(
172            designspace_name="Build",
173            font_name="TestFamily",
174            tables=["GDEF", "HVAR", "MVAR", "fvar", "gvar"],
175            expected_ttx_name="Build",
176        )
177
178    def test_varlib_build_no_axes_ttf(self):
179        """Designspace file does not contain an <axes> element."""
180        ds_path = self.get_test_input("InterpolateLayout3.designspace")
181        with self.assertRaisesRegex(DesignSpaceDocumentError, "No axes defined"):
182            build(ds_path)
183
184    def test_varlib_avar_single_axis(self):
185        """Designspace file contains a 'weight' axis with <map> elements
186        modifying the normalization mapping. An 'avar' table is generated.
187        """
188        test_name = "BuildAvarSingleAxis"
189        self._run_varlib_build_test(
190            designspace_name=test_name,
191            font_name="TestFamily3",
192            tables=["avar"],
193            expected_ttx_name=test_name,
194        )
195
196    def test_varlib_avar_with_identity_maps(self):
197        """Designspace file contains two 'weight' and 'width' axes both with
198        <map> elements.
199
200        The 'width' axis only contains identity mappings, however the resulting
201        avar segment will not be empty but will contain the default axis value
202        maps: {-1.0: -1.0, 0.0: 0.0, 1.0: 1.0}.
203
204        This is to work around an issue with some rasterizers:
205        https://github.com/googlei18n/fontmake/issues/295
206        https://github.com/fonttools/fonttools/issues/1011
207        """
208        test_name = "BuildAvarIdentityMaps"
209        self._run_varlib_build_test(
210            designspace_name=test_name,
211            font_name="TestFamily3",
212            tables=["avar"],
213            expected_ttx_name=test_name,
214        )
215
216    def test_varlib_avar_empty_axis(self):
217        """Designspace file contains two 'weight' and 'width' axes, but
218        only one axis ('weight') has some <map> elements.
219
220        Even if no <map> elements are defined for the 'width' axis, the
221        resulting avar segment still contains the default axis value maps:
222        {-1.0: -1.0, 0.0: 0.0, 1.0: 1.0}.
223
224        This is again to work around an issue with some rasterizers:
225        https://github.com/googlei18n/fontmake/issues/295
226        https://github.com/fonttools/fonttools/issues/1011
227        """
228        test_name = "BuildAvarEmptyAxis"
229        self._run_varlib_build_test(
230            designspace_name=test_name,
231            font_name="TestFamily3",
232            tables=["avar"],
233            expected_ttx_name=test_name,
234        )
235
236    def test_varlib_avar2(self):
237        """Designspace file contains a 'weight' axis with <map> elements
238        modifying the normalization mapping as well as <mappings> element
239        modifying it post-normalization. An 'avar' table is generated.
240        """
241        test_name = "BuildAvar2"
242        self._run_varlib_build_test(
243            designspace_name=test_name,
244            font_name="TestFamily3",
245            tables=["avar"],
246            expected_ttx_name=test_name,
247        )
248
249    def test_varlib_build_feature_variations(self):
250        """Designspace file contains <rules> element, used to build
251        GSUB FeatureVariations table.
252        """
253        self._run_varlib_build_test(
254            designspace_name="FeatureVars",
255            font_name="TestFamily",
256            tables=["fvar", "GSUB"],
257            expected_ttx_name="FeatureVars",
258            save_before_dump=True,
259        )
260
261    def test_varlib_build_feature_variations_custom_tag(self):
262        """Designspace file contains <rules> element, used to build
263        GSUB FeatureVariations table.
264        """
265        self._run_varlib_build_test(
266            designspace_name="FeatureVarsCustomTag",
267            font_name="TestFamily",
268            tables=["fvar", "GSUB"],
269            expected_ttx_name="FeatureVarsCustomTag",
270            save_before_dump=True,
271        )
272
273    def test_varlib_build_feature_variations_whole_range(self):
274        """Designspace file contains <rules> element specifying the entire design
275        space, used to build GSUB FeatureVariations table.
276        """
277        self._run_varlib_build_test(
278            designspace_name="FeatureVarsWholeRange",
279            font_name="TestFamily",
280            tables=["fvar", "GSUB"],
281            expected_ttx_name="FeatureVarsWholeRange",
282            save_before_dump=True,
283        )
284
285    def test_varlib_build_feature_variations_whole_range_empty(self):
286        """Designspace file contains <rules> element without a condition, specifying
287        the entire design space, used to build GSUB FeatureVariations table.
288        """
289        self._run_varlib_build_test(
290            designspace_name="FeatureVarsWholeRangeEmpty",
291            font_name="TestFamily",
292            tables=["fvar", "GSUB"],
293            expected_ttx_name="FeatureVarsWholeRange",
294            save_before_dump=True,
295        )
296
297    def test_varlib_build_feature_variations_with_existing_rclt(self):
298        """Designspace file contains <rules> element, used to build GSUB
299        FeatureVariations table. <rules> is specified to do its OT processing
300        "last", so a 'rclt' feature will be used or created. This test covers
301        the case when a 'rclt' already exists in the masters.
302
303        We dynamically add a 'rclt' feature to an existing set of test
304        masters, to avoid adding more test data.
305
306        The multiple languages are done to verify whether multiple existing
307        'rclt' features are updated correctly.
308        """
309
310        def add_rclt(font, savepath):
311            features = """
312            languagesystem DFLT dflt;
313            languagesystem latn dflt;
314            languagesystem latn NLD;
315
316            feature rclt {
317                script latn;
318                language NLD;
319                lookup A {
320                    sub uni0041 by uni0061;
321                } A;
322                language dflt;
323                lookup B {
324                    sub uni0041 by uni0061;
325                } B;
326            } rclt;
327            """
328            addOpenTypeFeaturesFromString(font, features)
329            font.save(savepath)
330
331        self._run_varlib_build_test(
332            designspace_name="FeatureVars",
333            font_name="TestFamily",
334            tables=["fvar", "GSUB"],
335            expected_ttx_name="FeatureVars_rclt",
336            save_before_dump=True,
337            post_process_master=add_rclt,
338        )
339
340    def test_varlib_gvar_explicit_delta(self):
341        """The variable font contains a composite glyph odieresis which does not
342        need a gvar entry, because all its deltas are 0, but it must be added
343        anyway to work around an issue with macOS 10.14.
344
345        https://github.com/fonttools/fonttools/issues/1381
346        """
347        test_name = "BuildGvarCompositeExplicitDelta"
348        self._run_varlib_build_test(
349            designspace_name=test_name,
350            font_name="TestFamily4",
351            tables=["gvar"],
352            expected_ttx_name=test_name,
353        )
354
355    def test_varlib_nonmarking_CFF2(self):
356        self.temp_dir()
357
358        ds_path = self.get_test_input("TestNonMarkingCFF2.designspace", copy=True)
359        ttx_dir = self.get_test_input("master_non_marking_cff2")
360        expected_ttx_path = self.get_test_output("TestNonMarkingCFF2.ttx")
361
362        for path in self.get_file_list(ttx_dir, ".ttx", "TestNonMarkingCFF2_"):
363            self.compile_font(path, ".otf", self.tempdir)
364
365        ds = DesignSpaceDocument.fromfile(ds_path)
366        for source in ds.sources:
367            source.path = os.path.join(
368                self.tempdir, os.path.basename(source.filename).replace(".ufo", ".otf")
369            )
370        ds.updatePaths()
371
372        varfont, _, _ = build(ds)
373        varfont = reload_font(varfont)
374
375        tables = ["CFF2"]
376        self.expect_ttx(varfont, expected_ttx_path, tables)
377
378    def test_varlib_build_CFF2(self):
379        self.temp_dir()
380
381        ds_path = self.get_test_input("TestCFF2.designspace", copy=True)
382        ttx_dir = self.get_test_input("master_cff2")
383        expected_ttx_path = self.get_test_output("BuildTestCFF2.ttx")
384
385        for path in self.get_file_list(ttx_dir, ".ttx", "TestCFF2_"):
386            self.compile_font(path, ".otf", self.tempdir)
387
388        ds = DesignSpaceDocument.fromfile(ds_path)
389        for source in ds.sources:
390            source.path = os.path.join(
391                self.tempdir, os.path.basename(source.filename).replace(".ufo", ".otf")
392            )
393        ds.updatePaths()
394
395        varfont, _, _ = build(ds)
396        varfont = reload_font(varfont)
397
398        tables = ["fvar", "CFF2"]
399        self.expect_ttx(varfont, expected_ttx_path, tables)
400
401    def test_varlib_build_CFF2_from_CFF2(self):
402        self.temp_dir()
403
404        ds_path = self.get_test_input("TestCFF2Input.designspace", copy=True)
405        ttx_dir = self.get_test_input("master_cff2_input")
406        expected_ttx_path = self.get_test_output("BuildTestCFF2.ttx")
407
408        for path in self.get_file_list(ttx_dir, ".ttx", "TestCFF2_"):
409            self.compile_font(path, ".otf", self.tempdir)
410
411        ds = DesignSpaceDocument.fromfile(ds_path)
412        for source in ds.sources:
413            source.path = os.path.join(
414                self.tempdir, os.path.basename(source.filename).replace(".ufo", ".otf")
415            )
416        ds.updatePaths()
417
418        varfont, _, _ = build(ds)
419        varfont = reload_font(varfont)
420
421        tables = ["fvar", "CFF2"]
422        self.expect_ttx(varfont, expected_ttx_path, tables)
423
424    def test_varlib_build_sparse_CFF2(self):
425        self.temp_dir()
426
427        ds_path = self.get_test_input("TestSparseCFF2VF.designspace", copy=True)
428        ttx_dir = self.get_test_input("master_sparse_cff2")
429        expected_ttx_path = self.get_test_output("TestSparseCFF2VF.ttx")
430
431        for path in self.get_file_list(ttx_dir, ".ttx", "MasterSet_Kanji-"):
432            self.compile_font(path, ".otf", self.tempdir)
433
434        ds = DesignSpaceDocument.fromfile(ds_path)
435        for source in ds.sources:
436            source.path = os.path.join(
437                self.tempdir, os.path.basename(source.filename).replace(".ufo", ".otf")
438            )
439        ds.updatePaths()
440
441        varfont, _, _ = build(ds)
442        varfont = reload_font(varfont)
443
444        tables = ["fvar", "CFF2"]
445        self.expect_ttx(varfont, expected_ttx_path, tables)
446
447    def test_varlib_build_vpal(self):
448        self.temp_dir()
449
450        ds_path = self.get_test_input("test_vpal.designspace", copy=True)
451        ttx_dir = self.get_test_input("master_vpal_test")
452        expected_ttx_path = self.get_test_output("test_vpal.ttx")
453
454        for path in self.get_file_list(ttx_dir, ".ttx", "master_vpal_test_"):
455            self.compile_font(path, ".otf", self.tempdir)
456
457        ds = DesignSpaceDocument.fromfile(ds_path)
458        for source in ds.sources:
459            source.path = os.path.join(
460                self.tempdir, os.path.basename(source.filename).replace(".ufo", ".otf")
461            )
462        ds.updatePaths()
463
464        varfont, _, _ = build(ds)
465        varfont = reload_font(varfont)
466
467        tables = ["GPOS"]
468        self.expect_ttx(varfont, expected_ttx_path, tables)
469
470    def test_varlib_main_ttf(self):
471        """Mostly for testing varLib.main()"""
472        suffix = ".ttf"
473        ds_path = self.get_test_input("Build.designspace")
474        ttx_dir = self.get_test_input("master_ttx_interpolatable_ttf")
475
476        self.temp_dir()
477        ttf_dir = os.path.join(self.tempdir, "master_ttf_interpolatable")
478        os.makedirs(ttf_dir)
479        ttx_paths = self.get_file_list(ttx_dir, ".ttx", "TestFamily-")
480        for path in ttx_paths:
481            self.compile_font(path, suffix, ttf_dir)
482
483        ds_copy = os.path.join(self.tempdir, "BuildMain.designspace")
484        shutil.copy2(ds_path, ds_copy)
485
486        # by default, varLib.main finds master TTFs inside a
487        # 'master_ttf_interpolatable' subfolder in current working dir
488        cwd = os.getcwd()
489        os.chdir(self.tempdir)
490        try:
491            varLib_main([ds_copy])
492        finally:
493            os.chdir(cwd)
494
495        varfont_path = os.path.splitext(ds_copy)[0] + "-VF" + suffix
496        self.assertTrue(os.path.exists(varfont_path))
497
498        # try again passing an explicit --master-finder
499        os.remove(varfont_path)
500        finder = "%s/master_ttf_interpolatable/{stem}.ttf" % self.tempdir
501        varLib_main([ds_copy, "--master-finder", finder])
502        self.assertTrue(os.path.exists(varfont_path))
503
504        # and also with explicit -o output option
505        os.remove(varfont_path)
506        varfont_path = os.path.splitext(varfont_path)[0] + "-o" + suffix
507        varLib_main([ds_copy, "-o", varfont_path, "--master-finder", finder])
508        self.assertTrue(os.path.exists(varfont_path))
509
510        varfont = TTFont(varfont_path)
511        tables = [table_tag for table_tag in varfont.keys() if table_tag != "head"]
512        expected_ttx_path = self.get_test_output("BuildMain.ttx")
513        self.expect_ttx(varfont, expected_ttx_path, tables)
514
515    def test_varLib_main_output_dir(self):
516        self.temp_dir()
517        outdir = os.path.join(self.tempdir, "output_dir_test")
518        self.assertFalse(os.path.exists(outdir))
519
520        ds_path = os.path.join(self.tempdir, "BuildMain.designspace")
521        shutil.copy2(self.get_test_input("Build.designspace"), ds_path)
522
523        shutil.copytree(
524            self.get_test_input("master_ttx_interpolatable_ttf"),
525            os.path.join(outdir, "master_ttx"),
526        )
527
528        finder = "%s/output_dir_test/master_ttx/{stem}.ttx" % self.tempdir
529
530        varLib_main([ds_path, "--output-dir", outdir, "--master-finder", finder])
531
532        self.assertTrue(os.path.isdir(outdir))
533        self.assertTrue(os.path.exists(os.path.join(outdir, "BuildMain-VF.ttf")))
534
535    def test_varLib_main_filter_variable_fonts(self):
536        self.temp_dir()
537        outdir = os.path.join(self.tempdir, "filter_variable_fonts_test")
538        self.assertFalse(os.path.exists(outdir))
539
540        ds_path = os.path.join(self.tempdir, "BuildMain.designspace")
541        shutil.copy2(self.get_test_input("Build.designspace"), ds_path)
542
543        shutil.copytree(
544            self.get_test_input("master_ttx_interpolatable_ttf"),
545            os.path.join(outdir, "master_ttx"),
546        )
547
548        finder = "%s/filter_variable_fonts_test/master_ttx/{stem}.ttx" % self.tempdir
549
550        cmd = [ds_path, "--output-dir", outdir, "--master-finder", finder]
551
552        with pytest.raises(SystemExit):
553            varLib_main(cmd + ["--variable-fonts", "FooBar"])  # no font matches
554
555        varLib_main(cmd + ["--variable-fonts", "Build.*"])  # this does match
556
557        self.assertTrue(os.path.isdir(outdir))
558        self.assertTrue(os.path.exists(os.path.join(outdir, "BuildMain-VF.ttf")))
559
560    def test_varLib_main_drop_implied_oncurves(self):
561        self.temp_dir()
562        outdir = os.path.join(self.tempdir, "drop_implied_oncurves_test")
563        self.assertFalse(os.path.exists(outdir))
564
565        ttf_dir = os.path.join(outdir, "master_ttf_interpolatable")
566        os.makedirs(ttf_dir)
567        ttx_dir = self.get_test_input("master_ttx_drop_oncurves")
568        ttx_paths = self.get_file_list(ttx_dir, ".ttx", "TestFamily-")
569        for path in ttx_paths:
570            self.compile_font(path, ".ttf", ttf_dir)
571
572        ds_copy = os.path.join(outdir, "DropOnCurves.designspace")
573        ds_path = self.get_test_input("DropOnCurves.designspace")
574        shutil.copy2(ds_path, ds_copy)
575
576        finder = "%s/master_ttf_interpolatable/{stem}.ttf" % outdir
577        varLib_main([ds_copy, "--master-finder", finder, "--drop-implied-oncurves"])
578
579        vf_path = os.path.join(outdir, "DropOnCurves-VF.ttf")
580        varfont = TTFont(vf_path)
581        tables = [table_tag for table_tag in varfont.keys() if table_tag != "head"]
582        expected_ttx_path = self.get_test_output("DropOnCurves.ttx")
583        self.expect_ttx(varfont, expected_ttx_path, tables)
584
585    def test_varLib_build_many_no_overwrite_STAT(self):
586        # Ensure that varLib.build_many doesn't overwrite a pre-existing STAT table,
587        # e.g. one built by feaLib from features.fea; the VF simply should inherit the
588        # STAT from the base master: https://github.com/googlefonts/fontmake/issues/985
589        base_master = TTFont()
590        base_master.importXML(
591            self.get_test_input("master_no_overwrite_stat/Test-CondensedThin.ttx")
592        )
593        assert "STAT" in base_master
594
595        vf = next(
596            iter(
597                build_many(
598                    DesignSpaceDocument.fromfile(
599                        self.get_test_input("TestNoOverwriteSTAT.designspace")
600                    )
601                ).values()
602            )
603        )
604        assert "STAT" in vf
605
606        assert vf["STAT"].table == base_master["STAT"].table
607
608    def test_varlib_build_from_ds_object_in_memory_ttfonts(self):
609        ds_path = self.get_test_input("Build.designspace")
610        ttx_dir = self.get_test_input("master_ttx_interpolatable_ttf")
611        expected_ttx_path = self.get_test_output("BuildMain.ttx")
612
613        self.temp_dir()
614        for path in self.get_file_list(ttx_dir, ".ttx", "TestFamily-"):
615            self.compile_font(path, ".ttf", self.tempdir)
616
617        ds = DesignSpaceDocument.fromfile(ds_path)
618        for source in ds.sources:
619            filename = os.path.join(
620                self.tempdir, os.path.basename(source.filename).replace(".ufo", ".ttf")
621            )
622            source.font = TTFont(
623                filename, recalcBBoxes=False, recalcTimestamp=False, lazy=True
624            )
625            source.filename = None  # Make sure no file path gets into build()
626
627        varfont, _, _ = build(ds)
628        varfont = reload_font(varfont)
629        tables = [table_tag for table_tag in varfont.keys() if table_tag != "head"]
630        self.expect_ttx(varfont, expected_ttx_path, tables)
631
632    def test_varlib_build_from_ttf_paths(self):
633        self.temp_dir()
634
635        ds_path = self.get_test_input("Build.designspace", copy=True)
636        ttx_dir = self.get_test_input("master_ttx_interpolatable_ttf")
637        expected_ttx_path = self.get_test_output("BuildMain.ttx")
638
639        for path in self.get_file_list(ttx_dir, ".ttx", "TestFamily-"):
640            self.compile_font(path, ".ttf", self.tempdir)
641
642        ds = DesignSpaceDocument.fromfile(ds_path)
643        for source in ds.sources:
644            source.path = os.path.join(
645                self.tempdir, os.path.basename(source.filename).replace(".ufo", ".ttf")
646            )
647        ds.updatePaths()
648
649        varfont, _, _ = build(ds)
650        varfont = reload_font(varfont)
651        tables = [table_tag for table_tag in varfont.keys() if table_tag != "head"]
652        self.expect_ttx(varfont, expected_ttx_path, tables)
653
654    def test_varlib_build_from_ttx_paths(self):
655        ds_path = self.get_test_input("Build.designspace")
656        ttx_dir = self.get_test_input("master_ttx_interpolatable_ttf")
657        expected_ttx_path = self.get_test_output("BuildMain.ttx")
658
659        ds = DesignSpaceDocument.fromfile(ds_path)
660        for source in ds.sources:
661            source.path = os.path.join(
662                ttx_dir, os.path.basename(source.filename).replace(".ufo", ".ttx")
663            )
664        ds.updatePaths()
665
666        varfont, _, _ = build(ds)
667        varfont = reload_font(varfont)
668        tables = [table_tag for table_tag in varfont.keys() if table_tag != "head"]
669        self.expect_ttx(varfont, expected_ttx_path, tables)
670
671    def test_varlib_build_sparse_masters(self):
672        ds_path = self.get_test_input("SparseMasters.designspace")
673        expected_ttx_path = self.get_test_output("SparseMasters.ttx")
674
675        varfont, _, _ = build(ds_path)
676        varfont = reload_font(varfont)
677        tables = [table_tag for table_tag in varfont.keys() if table_tag != "head"]
678        self.expect_ttx(varfont, expected_ttx_path, tables)
679
680    def test_varlib_build_lazy_masters(self):
681        # See https://github.com/fonttools/fonttools/issues/1808
682        ds_path = self.get_test_input("SparseMasters.designspace")
683        expected_ttx_path = self.get_test_output("SparseMasters.ttx")
684
685        def _open_font(master_path, master_finder=lambda s: s):
686            font = TTFont()
687            font.importXML(master_path)
688            buf = BytesIO()
689            font.save(buf, reorderTables=False)
690            buf.seek(0)
691            font = TTFont(buf, lazy=True)  # reopen in lazy mode, to reproduce #1808
692            return font
693
694        ds = DesignSpaceDocument.fromfile(ds_path)
695        ds.loadSourceFonts(_open_font)
696        varfont, _, _ = build(ds)
697        varfont = reload_font(varfont)
698        tables = [table_tag for table_tag in varfont.keys() if table_tag != "head"]
699        self.expect_ttx(varfont, expected_ttx_path, tables)
700
701    def test_varlib_build_sparse_masters_MVAR(self):
702        import fontTools.varLib.mvar
703
704        ds_path = self.get_test_input("SparseMasters.designspace")
705        ds = DesignSpaceDocument.fromfile(ds_path)
706        load_masters(ds)
707
708        # Trigger MVAR generation so varLib is forced to create deltas with a
709        # sparse master inbetween.
710        font_0_os2 = ds.sources[0].font["OS/2"]
711        font_0_os2.sTypoAscender = 1
712        font_0_os2.sTypoDescender = 1
713        font_0_os2.sTypoLineGap = 1
714        font_0_os2.usWinAscent = 1
715        font_0_os2.usWinDescent = 1
716        font_0_os2.sxHeight = 1
717        font_0_os2.sCapHeight = 1
718        font_0_os2.ySubscriptXSize = 1
719        font_0_os2.ySubscriptYSize = 1
720        font_0_os2.ySubscriptXOffset = 1
721        font_0_os2.ySubscriptYOffset = 1
722        font_0_os2.ySuperscriptXSize = 1
723        font_0_os2.ySuperscriptYSize = 1
724        font_0_os2.ySuperscriptXOffset = 1
725        font_0_os2.ySuperscriptYOffset = 1
726        font_0_os2.yStrikeoutSize = 1
727        font_0_os2.yStrikeoutPosition = 1
728        font_0_vhea = newTable("vhea")
729        font_0_vhea.ascent = 1
730        font_0_vhea.descent = 1
731        font_0_vhea.lineGap = 1
732        font_0_vhea.caretSlopeRise = 1
733        font_0_vhea.caretSlopeRun = 1
734        font_0_vhea.caretOffset = 1
735        ds.sources[0].font["vhea"] = font_0_vhea
736        font_0_hhea = ds.sources[0].font["hhea"]
737        font_0_hhea.caretSlopeRise = 1
738        font_0_hhea.caretSlopeRun = 1
739        font_0_hhea.caretOffset = 1
740        font_0_post = ds.sources[0].font["post"]
741        font_0_post.underlineThickness = 1
742        font_0_post.underlinePosition = 1
743
744        font_2_os2 = ds.sources[2].font["OS/2"]
745        font_2_os2.sTypoAscender = 800
746        font_2_os2.sTypoDescender = 800
747        font_2_os2.sTypoLineGap = 800
748        font_2_os2.usWinAscent = 800
749        font_2_os2.usWinDescent = 800
750        font_2_os2.sxHeight = 800
751        font_2_os2.sCapHeight = 800
752        font_2_os2.ySubscriptXSize = 800
753        font_2_os2.ySubscriptYSize = 800
754        font_2_os2.ySubscriptXOffset = 800
755        font_2_os2.ySubscriptYOffset = 800
756        font_2_os2.ySuperscriptXSize = 800
757        font_2_os2.ySuperscriptYSize = 800
758        font_2_os2.ySuperscriptXOffset = 800
759        font_2_os2.ySuperscriptYOffset = 800
760        font_2_os2.yStrikeoutSize = 800
761        font_2_os2.yStrikeoutPosition = 800
762        font_2_vhea = newTable("vhea")
763        font_2_vhea.ascent = 800
764        font_2_vhea.descent = 800
765        font_2_vhea.lineGap = 800
766        font_2_vhea.caretSlopeRise = 800
767        font_2_vhea.caretSlopeRun = 800
768        font_2_vhea.caretOffset = 800
769        ds.sources[2].font["vhea"] = font_2_vhea
770        font_2_hhea = ds.sources[2].font["hhea"]
771        font_2_hhea.caretSlopeRise = 800
772        font_2_hhea.caretSlopeRun = 800
773        font_2_hhea.caretOffset = 800
774        font_2_post = ds.sources[2].font["post"]
775        font_2_post.underlineThickness = 800
776        font_2_post.underlinePosition = 800
777
778        varfont, _, _ = build(ds)
779        mvar_tags = [vr.ValueTag for vr in varfont["MVAR"].table.ValueRecord]
780        assert all(tag in mvar_tags for tag in fontTools.varLib.mvar.MVAR_ENTRIES)
781
782    def test_varlib_build_VVAR_CFF2(self):
783        self.temp_dir()
784
785        ds_path = self.get_test_input("TestVVAR.designspace", copy=True)
786        ttx_dir = self.get_test_input("master_vvar_cff2")
787        expected_ttx_name = "TestVVAR"
788        suffix = ".otf"
789
790        for path in self.get_file_list(ttx_dir, ".ttx", "TestVVAR"):
791            font, savepath = self.compile_font(path, suffix, self.tempdir)
792
793        ds = DesignSpaceDocument.fromfile(ds_path)
794        for source in ds.sources:
795            source.path = os.path.join(
796                self.tempdir, os.path.basename(source.filename).replace(".ufo", suffix)
797            )
798        ds.updatePaths()
799
800        varfont, _, _ = build(ds)
801        varfont = reload_font(varfont)
802
803        expected_ttx_path = self.get_test_output(expected_ttx_name + ".ttx")
804        tables = ["VVAR"]
805        self.expect_ttx(varfont, expected_ttx_path, tables)
806        self.check_ttx_dump(varfont, expected_ttx_path, tables, suffix)
807
808    def test_varlib_build_BASE(self):
809        self.temp_dir()
810
811        ds_path = self.get_test_input("TestBASE.designspace", copy=True)
812        ttx_dir = self.get_test_input("master_base_test")
813        expected_ttx_name = "TestBASE"
814        suffix = ".otf"
815
816        for path in self.get_file_list(ttx_dir, ".ttx", "TestBASE"):
817            font, savepath = self.compile_font(path, suffix, self.tempdir)
818
819        ds = DesignSpaceDocument.fromfile(ds_path)
820        for source in ds.sources:
821            source.path = os.path.join(
822                self.tempdir, os.path.basename(source.filename).replace(".ufo", suffix)
823            )
824        ds.updatePaths()
825
826        varfont, _, _ = build(ds)
827        varfont = reload_font(varfont)
828
829        expected_ttx_path = self.get_test_output(expected_ttx_name + ".ttx")
830        tables = ["BASE"]
831        self.expect_ttx(varfont, expected_ttx_path, tables)
832        self.check_ttx_dump(varfont, expected_ttx_path, tables, suffix)
833
834    def test_varlib_build_single_master(self):
835        self._run_varlib_build_test(
836            designspace_name="SingleMaster",
837            font_name="TestFamily",
838            tables=["GDEF", "HVAR", "MVAR", "STAT", "fvar", "cvar", "gvar", "name"],
839            expected_ttx_name="SingleMaster",
840            save_before_dump=True,
841        )
842
843    def test_kerning_merging(self):
844        """Test the correct merging of class-based pair kerning.
845
846        Problem description at https://github.com/fonttools/fonttools/pull/1638.
847        Test font and Designspace generated by
848        https://gist.github.com/madig/183d0440c9f7d05f04bd1280b9664bd1.
849        """
850        ds_path = self.get_test_input("KerningMerging.designspace")
851        ttx_dir = self.get_test_input("master_kerning_merging")
852
853        ds = DesignSpaceDocument.fromfile(ds_path)
854        for source in ds.sources:
855            ttx_dump = TTFont()
856            ttx_dump.importXML(
857                os.path.join(
858                    ttx_dir, os.path.basename(source.filename).replace(".ttf", ".ttx")
859                )
860            )
861            source.font = reload_font(ttx_dump)
862
863        varfont, _, _ = build(ds)
864        varfont = reload_font(varfont)
865
866        class_kerning_tables = [
867            t
868            for l in varfont["GPOS"].table.LookupList.Lookup
869            for t in l.SubTable
870            if t.Format == 2
871        ]
872        assert len(class_kerning_tables) == 1
873        class_kerning_table = class_kerning_tables[0]
874
875        # Test that no class kerned against class zero (containing all glyphs not
876        # classed) has a `XAdvDevice` table attached, which in the variable font
877        # context is a "VariationIndex" table and points to kerning deltas in the GDEF
878        # table. Variation deltas of any kerning class against class zero should
879        # probably never exist.
880        for class1_record in class_kerning_table.Class1Record:
881            class2_zero = class1_record.Class2Record[0]
882            assert getattr(class2_zero.Value1, "XAdvDevice", None) is None
883
884        # Assert the variable font's kerning table (without deltas) is equal to the
885        # default font's kerning table. The bug fixed in
886        # https://github.com/fonttools/fonttools/pull/1638 caused rogue kerning
887        # values to be written to the variable font.
888        assert _extract_flat_kerning(varfont, class_kerning_table) == {
889            ("A", ".notdef"): 0,
890            ("A", "A"): 0,
891            ("A", "B"): -20,
892            ("A", "C"): 0,
893            ("A", "D"): -20,
894            ("B", ".notdef"): 0,
895            ("B", "A"): 0,
896            ("B", "B"): 0,
897            ("B", "C"): 0,
898            ("B", "D"): 0,
899        }
900
901        instance_thin = instantiateVariableFont(varfont, {"wght": 100})
902        instance_thin_kerning_table = (
903            instance_thin["GPOS"].table.LookupList.Lookup[0].SubTable[0]
904        )
905        assert _extract_flat_kerning(instance_thin, instance_thin_kerning_table) == {
906            ("A", ".notdef"): 0,
907            ("A", "A"): 0,
908            ("A", "B"): 0,
909            ("A", "C"): 10,
910            ("A", "D"): 0,
911            ("B", ".notdef"): 0,
912            ("B", "A"): 0,
913            ("B", "B"): 0,
914            ("B", "C"): 10,
915            ("B", "D"): 0,
916        }
917
918        instance_black = instantiateVariableFont(varfont, {"wght": 900})
919        instance_black_kerning_table = (
920            instance_black["GPOS"].table.LookupList.Lookup[0].SubTable[0]
921        )
922        assert _extract_flat_kerning(instance_black, instance_black_kerning_table) == {
923            ("A", ".notdef"): 0,
924            ("A", "A"): 0,
925            ("A", "B"): 0,
926            ("A", "C"): 0,
927            ("A", "D"): 40,
928            ("B", ".notdef"): 0,
929            ("B", "A"): 0,
930            ("B", "B"): 0,
931            ("B", "C"): 0,
932            ("B", "D"): 40,
933        }
934
935    def test_designspace_fill_in_location(self):
936        ds_path = self.get_test_input("VarLibLocationTest.designspace")
937        ds = DesignSpaceDocument.fromfile(ds_path)
938        ds_loaded = load_designspace(ds)
939
940        assert ds_loaded.instances[0].location == {"weight": 0, "width": 50}
941
942    def test_varlib_build_incompatible_features(self):
943        with pytest.raises(
944            varLibErrors.ShouldBeConstant,
945            match="""
946
947Couldn't merge the fonts, because some values were different, but should have
948been the same. This happened while performing the following operation:
949GPOS.table.FeatureList.FeatureCount
950
951The problem is likely to be in Simple Two Axis Bold:
952Expected to see .FeatureCount==2, instead saw 1
953
954Incompatible features between masters.
955Expected: kern, mark.
956Got: kern.
957""",
958        ):
959            self._run_varlib_build_test(
960                designspace_name="IncompatibleFeatures",
961                font_name="IncompatibleFeatures",
962                tables=["GPOS"],
963                expected_ttx_name="IncompatibleFeatures",
964                save_before_dump=True,
965            )
966
967    def test_varlib_build_incompatible_lookup_types(self):
968        with pytest.raises(
969            varLibErrors.MismatchedTypes, match=r"'MarkBasePos', instead saw 'PairPos'"
970        ):
971            self._run_varlib_build_test(
972                designspace_name="IncompatibleLookupTypes",
973                font_name="IncompatibleLookupTypes",
974                tables=["GPOS"],
975                expected_ttx_name="IncompatibleLookupTypes",
976                save_before_dump=True,
977            )
978
979    def test_varlib_build_incompatible_arrays(self):
980        with pytest.raises(
981            varLibErrors.ShouldBeConstant,
982            match="""
983
984Couldn't merge the fonts, because some values were different, but should have
985been the same. This happened while performing the following operation:
986GPOS.table.ScriptList.ScriptCount
987
988The problem is likely to be in Simple Two Axis Bold:
989Expected to see .ScriptCount==1, instead saw 0""",
990        ):
991            self._run_varlib_build_test(
992                designspace_name="IncompatibleArrays",
993                font_name="IncompatibleArrays",
994                tables=["GPOS"],
995                expected_ttx_name="IncompatibleArrays",
996                save_before_dump=True,
997            )
998
999    def test_varlib_build_variable_colr(self):
1000        self._run_varlib_build_test(
1001            designspace_name="TestVariableCOLR",
1002            font_name="TestVariableCOLR",
1003            tables=["GlyphOrder", "fvar", "glyf", "COLR", "CPAL"],
1004            expected_ttx_name="TestVariableCOLR-VF",
1005            save_before_dump=True,
1006        )
1007
1008    def test_varlib_build_variable_cff2_with_empty_sparse_glyph(self):
1009        # https://github.com/fonttools/fonttools/issues/3233
1010        self._run_varlib_build_test(
1011            designspace_name="SparseCFF2",
1012            font_name="SparseCFF2",
1013            tables=["GlyphOrder", "CFF2", "fvar", "hmtx", "HVAR"],
1014            expected_ttx_name="SparseCFF2-VF",
1015            save_before_dump=True,
1016        )
1017
1018    def test_varlib_addGSUBFeatureVariations(self):
1019        ttx_dir = self.get_test_input("master_ttx_interpolatable_ttf")
1020
1021        ds = DesignSpaceDocument.fromfile(
1022            self.get_test_input("FeatureVars.designspace")
1023        )
1024        for source in ds.sources:
1025            ttx_dump = TTFont()
1026            ttx_dump.importXML(
1027                os.path.join(
1028                    ttx_dir, os.path.basename(source.filename).replace(".ufo", ".ttx")
1029                )
1030            )
1031            source.font = ttx_dump
1032
1033        varfont, _, _ = build(ds, exclude=["GSUB"])
1034        assert "GSUB" not in varfont
1035
1036        addGSUBFeatureVariations(varfont, ds)
1037        assert "GSUB" in varfont
1038
1039        tables = ["fvar", "GSUB"]
1040        expected_ttx_path = self.get_test_output("FeatureVars.ttx")
1041        self.expect_ttx(varfont, expected_ttx_path, tables)
1042        self.check_ttx_dump(varfont, expected_ttx_path, tables, ".ttf")
1043
1044
1045def test_load_masters_layerName_without_required_font():
1046    ds = DesignSpaceDocument()
1047    s = SourceDescriptor()
1048    s.font = None
1049    s.layerName = "Medium"
1050    ds.addSource(s)
1051
1052    with pytest.raises(
1053        VarLibValidationError,
1054        match="specified a layer name but lacks the required TTFont object",
1055    ):
1056        load_masters(ds)
1057
1058
1059def _extract_flat_kerning(font, pairpos_table):
1060    extracted_kerning = {}
1061    for glyph_name_1 in pairpos_table.Coverage.glyphs:
1062        class_def_1 = pairpos_table.ClassDef1.classDefs.get(glyph_name_1, 0)
1063        for glyph_name_2 in font.getGlyphOrder():
1064            class_def_2 = pairpos_table.ClassDef2.classDefs.get(glyph_name_2, 0)
1065            kern_value = (
1066                pairpos_table.Class1Record[class_def_1]
1067                .Class2Record[class_def_2]
1068                .Value1.XAdvance
1069            )
1070            extracted_kerning[(glyph_name_1, glyph_name_2)] = kern_value
1071    return extracted_kerning
1072
1073
1074@pytest.fixture
1075def ttFont():
1076    f = TTFont()
1077    f["OS/2"] = newTable("OS/2")
1078    f["OS/2"].usWeightClass = 400
1079    f["OS/2"].usWidthClass = 100
1080    f["post"] = newTable("post")
1081    f["post"].italicAngle = 0
1082    return f
1083
1084
1085class SetDefaultWeightWidthSlantTest(object):
1086    @pytest.mark.parametrize(
1087        "location, expected",
1088        [
1089            ({"wght": 0}, 1),
1090            ({"wght": 1}, 1),
1091            ({"wght": 100}, 100),
1092            ({"wght": 1000}, 1000),
1093            ({"wght": 1001}, 1000),
1094        ],
1095    )
1096    def test_wght(self, ttFont, location, expected):
1097        set_default_weight_width_slant(ttFont, location)
1098
1099        assert ttFont["OS/2"].usWeightClass == expected
1100
1101    @pytest.mark.parametrize(
1102        "location, expected",
1103        [
1104            ({"wdth": 0}, 1),
1105            ({"wdth": 56}, 1),
1106            ({"wdth": 57}, 2),
1107            ({"wdth": 62.5}, 2),
1108            ({"wdth": 75}, 3),
1109            ({"wdth": 87.5}, 4),
1110            ({"wdth": 100}, 5),
1111            ({"wdth": 112.5}, 6),
1112            ({"wdth": 125}, 7),
1113            ({"wdth": 150}, 8),
1114            ({"wdth": 200}, 9),
1115            ({"wdth": 201}, 9),
1116            ({"wdth": 1000}, 9),
1117        ],
1118    )
1119    def test_wdth(self, ttFont, location, expected):
1120        set_default_weight_width_slant(ttFont, location)
1121
1122        assert ttFont["OS/2"].usWidthClass == expected
1123
1124    @pytest.mark.parametrize(
1125        "location, expected",
1126        [
1127            ({"slnt": -91}, -90),
1128            ({"slnt": -90}, -90),
1129            ({"slnt": 0}, 0),
1130            ({"slnt": 11.5}, 11.5),
1131            ({"slnt": 90}, 90),
1132            ({"slnt": 91}, 90),
1133        ],
1134    )
1135    def test_slnt(self, ttFont, location, expected):
1136        set_default_weight_width_slant(ttFont, location)
1137
1138        assert ttFont["post"].italicAngle == expected
1139
1140    def test_all(self, ttFont):
1141        set_default_weight_width_slant(
1142            ttFont, {"wght": 500, "wdth": 150, "slnt": -12.0}
1143        )
1144
1145        assert ttFont["OS/2"].usWeightClass == 500
1146        assert ttFont["OS/2"].usWidthClass == 8
1147        assert ttFont["post"].italicAngle == -12.0
1148
1149
1150def test_variable_COLR_without_VarIndexMap():
1151    # test we don't add a no-op VarIndexMap to variable COLR when not needed
1152    # https://github.com/fonttools/fonttools/issues/2800
1153
1154    font1 = TTFont()
1155    font1.setGlyphOrder([".notdef", "A"])
1156    font1["COLR"] = buildCOLR({"A": (ot.PaintFormat.PaintSolid, 0, 1.0)})
1157    # font2 == font1 except for PaintSolid.Alpha
1158    font2 = deepcopy(font1)
1159    font2["COLR"].table.BaseGlyphList.BaseGlyphPaintRecord[0].Paint.Alpha = 0.0
1160    master_fonts = [font1, font2]
1161
1162    varfont = deepcopy(font1)
1163    axis_order = ["XXXX"]
1164    model = VariationModel([{}, {"XXXX": 1.0}], axis_order)
1165
1166    _add_COLR(varfont, model, master_fonts, axis_order)
1167
1168    colr = varfont["COLR"].table
1169
1170    assert len(colr.BaseGlyphList.BaseGlyphPaintRecord) == 1
1171    baserec = colr.BaseGlyphList.BaseGlyphPaintRecord[0]
1172    assert baserec.Paint.Format == ot.PaintFormat.PaintVarSolid
1173    assert baserec.Paint.VarIndexBase == 0
1174
1175    assert colr.VarStore is not None
1176    assert len(colr.VarStore.VarData) == 1
1177    assert len(colr.VarStore.VarData[0].Item) == 1
1178    assert colr.VarStore.VarData[0].Item[0] == [-16384]
1179
1180    assert colr.VarIndexMap is None
1181
1182
1183if __name__ == "__main__":
1184    sys.exit(unittest.main())
1185