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