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