• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1from __future__ import print_function, division, absolute_import
2from fontTools.misc.py23 import *
3from fontTools.ttLib import TTFont, newTable
4from fontTools.varLib import build
5from fontTools.varLib import main as varLib_main, load_masters
6from fontTools.designspaceLib import (
7    DesignSpaceDocumentError, DesignSpaceDocument, SourceDescriptor,
8)
9import difflib
10import os
11import shutil
12import sys
13import tempfile
14import unittest
15import pytest
16
17
18def reload_font(font):
19    """(De)serialize to get final binary layout."""
20    buf = BytesIO()
21    font.save(buf)
22    buf.seek(0)
23    return TTFont(buf)
24
25
26class BuildTest(unittest.TestCase):
27    def __init__(self, methodName):
28        unittest.TestCase.__init__(self, methodName)
29        # Python 3 renamed assertRaisesRegexp to assertRaisesRegex,
30        # and fires deprecation warnings if a program uses the old name.
31        if not hasattr(self, "assertRaisesRegex"):
32            self.assertRaisesRegex = self.assertRaisesRegexp
33
34    def setUp(self):
35        self.tempdir = None
36        self.num_tempfiles = 0
37
38    def tearDown(self):
39        if self.tempdir:
40            shutil.rmtree(self.tempdir)
41
42    @staticmethod
43    def get_test_input(test_file_or_folder):
44        path, _ = os.path.split(__file__)
45        return os.path.join(path, "data", test_file_or_folder)
46
47    @staticmethod
48    def get_test_output(test_file_or_folder):
49        path, _ = os.path.split(__file__)
50        return os.path.join(path, "data", "test_results", test_file_or_folder)
51
52    @staticmethod
53    def get_file_list(folder, suffix, prefix=''):
54        all_files = os.listdir(folder)
55        file_list = []
56        for p in all_files:
57            if p.startswith(prefix) and p.endswith(suffix):
58                file_list.append(os.path.abspath(os.path.join(folder, p)))
59        return file_list
60
61    def temp_path(self, suffix):
62        self.temp_dir()
63        self.num_tempfiles += 1
64        return os.path.join(self.tempdir,
65                            "tmp%d%s" % (self.num_tempfiles, suffix))
66
67    def temp_dir(self):
68        if not self.tempdir:
69            self.tempdir = tempfile.mkdtemp()
70
71    def read_ttx(self, path):
72        lines = []
73        with open(path, "r", encoding="utf-8") as ttx:
74            for line in ttx.readlines():
75                # Elide ttFont attributes because ttLibVersion may change,
76                # and use os-native line separators so we can run difflib.
77                if line.startswith("<ttFont "):
78                    lines.append("<ttFont>" + os.linesep)
79                else:
80                    lines.append(line.rstrip() + os.linesep)
81        return lines
82
83    def expect_ttx(self, font, expected_ttx, tables):
84        path = self.temp_path(suffix=".ttx")
85        font.saveXML(path, tables=tables)
86        actual = self.read_ttx(path)
87        expected = self.read_ttx(expected_ttx)
88        if actual != expected:
89            for line in difflib.unified_diff(
90                    expected, actual, fromfile=expected_ttx, tofile=path):
91                sys.stdout.write(line)
92            self.fail("TTX output is different from expected")
93
94    def check_ttx_dump(self, font, expected_ttx, tables, suffix):
95        """Ensure the TTX dump is the same after saving and reloading the font."""
96        path = self.temp_path(suffix=suffix)
97        font.save(path)
98        self.expect_ttx(TTFont(path), expected_ttx, tables)
99
100    def compile_font(self, path, suffix, temp_dir):
101        ttx_filename = os.path.basename(path)
102        savepath = os.path.join(temp_dir, ttx_filename.replace('.ttx', suffix))
103        font = TTFont(recalcBBoxes=False, recalcTimestamp=False)
104        font.importXML(path)
105        font.save(savepath, reorderTables=None)
106        return font, savepath
107
108    def _run_varlib_build_test(self, designspace_name, font_name, tables,
109                               expected_ttx_name, save_before_dump=False):
110        suffix = '.ttf'
111        ds_path = self.get_test_input(designspace_name + '.designspace')
112        ufo_dir = self.get_test_input('master_ufo')
113        ttx_dir = self.get_test_input('master_ttx_interpolatable_ttf')
114
115        self.temp_dir()
116        ttx_paths = self.get_file_list(ttx_dir, '.ttx', font_name + '-')
117        for path in ttx_paths:
118            self.compile_font(path, suffix, self.tempdir)
119
120        finder = lambda s: s.replace(ufo_dir, self.tempdir).replace('.ufo', suffix)
121        varfont, model, _ = build(ds_path, finder)
122
123        if save_before_dump:
124            # some data (e.g. counts printed in TTX inline comments) is only
125            # calculated at compile time, so before we can compare the TTX
126            # dumps we need to save to a temporary stream, and realod the font
127            varfont = reload_font(varfont)
128
129        expected_ttx_path = self.get_test_output(expected_ttx_name + '.ttx')
130        self.expect_ttx(varfont, expected_ttx_path, tables)
131        self.check_ttx_dump(varfont, expected_ttx_path, tables, suffix)
132# -----
133# Tests
134# -----
135
136    def test_varlib_build_ttf(self):
137        """Designspace file contains <axes> element."""
138        self._run_varlib_build_test(
139            designspace_name='Build',
140            font_name='TestFamily',
141            tables=['GDEF', 'HVAR', 'MVAR', 'fvar', 'gvar'],
142            expected_ttx_name='Build'
143        )
144
145    def test_varlib_build_no_axes_ttf(self):
146        """Designspace file does not contain an <axes> element."""
147        ds_path = self.get_test_input('InterpolateLayout3.designspace')
148        with self.assertRaisesRegex(DesignSpaceDocumentError, "No axes defined"):
149            build(ds_path)
150
151    def test_varlib_avar_single_axis(self):
152        """Designspace file contains a 'weight' axis with <map> elements
153        modifying the normalization mapping. An 'avar' table is generated.
154        """
155        test_name = 'BuildAvarSingleAxis'
156        self._run_varlib_build_test(
157            designspace_name=test_name,
158            font_name='TestFamily3',
159            tables=['avar'],
160            expected_ttx_name=test_name
161        )
162
163    def test_varlib_avar_with_identity_maps(self):
164        """Designspace file contains two 'weight' and 'width' axes both with
165        <map> elements.
166
167        The 'width' axis only contains identity mappings, however the resulting
168        avar segment will not be empty but will contain the default axis value
169        maps: {-1.0: -1.0, 0.0: 0.0, 1.0: 1.0}.
170
171        This is to work around an issue with some rasterizers:
172        https://github.com/googlei18n/fontmake/issues/295
173        https://github.com/fonttools/fonttools/issues/1011
174        """
175        test_name = 'BuildAvarIdentityMaps'
176        self._run_varlib_build_test(
177            designspace_name=test_name,
178            font_name='TestFamily3',
179            tables=['avar'],
180            expected_ttx_name=test_name
181        )
182
183    def test_varlib_avar_empty_axis(self):
184        """Designspace file contains two 'weight' and 'width' axes, but
185        only one axis ('weight') has some <map> elements.
186
187        Even if no <map> elements are defined for the 'width' axis, the
188        resulting avar segment still contains the default axis value maps:
189        {-1.0: -1.0, 0.0: 0.0, 1.0: 1.0}.
190
191        This is again to work around an issue with some rasterizers:
192        https://github.com/googlei18n/fontmake/issues/295
193        https://github.com/fonttools/fonttools/issues/1011
194        """
195        test_name = 'BuildAvarEmptyAxis'
196        self._run_varlib_build_test(
197            designspace_name=test_name,
198            font_name='TestFamily3',
199            tables=['avar'],
200            expected_ttx_name=test_name
201        )
202
203    def test_varlib_build_feature_variations(self):
204        """Designspace file contains <rules> element, used to build
205        GSUB FeatureVariations table.
206        """
207        self._run_varlib_build_test(
208            designspace_name="FeatureVars",
209            font_name="TestFamily",
210            tables=["fvar", "GSUB"],
211            expected_ttx_name="FeatureVars",
212            save_before_dump=True,
213        )
214
215    def test_varlib_gvar_explicit_delta(self):
216        """The variable font contains a composite glyph odieresis which does not
217        need a gvar entry, because all its deltas are 0, but it must be added
218        anyway to work around an issue with macOS 10.14.
219
220        https://github.com/fonttools/fonttools/issues/1381
221        """
222        test_name = 'BuildGvarCompositeExplicitDelta'
223        self._run_varlib_build_test(
224            designspace_name=test_name,
225            font_name='TestFamily4',
226            tables=['gvar'],
227            expected_ttx_name=test_name
228        )
229
230    def test_varlib_build_CFF2(self):
231        ds_path = self.get_test_input('TestCFF2.designspace')
232        suffix = '.otf'
233        expected_ttx_name = 'BuildTestCFF2'
234        tables = ["fvar", "CFF2"]
235
236        finder = lambda s: s.replace('.ufo', suffix)
237        varfont, model, _ = build(ds_path, finder)
238        # some data (e.g. counts printed in TTX inline comments) is only
239        # calculated at compile time, so before we can compare the TTX
240        # dumps we need to save to a temporary stream, and realod the font
241        varfont = reload_font(varfont)
242
243        expected_ttx_path = self.get_test_output(expected_ttx_name + '.ttx')
244        self.expect_ttx(varfont, expected_ttx_path, tables)
245        self.check_ttx_dump(varfont, expected_ttx_path, tables, suffix)
246
247    def test_varlib_main_ttf(self):
248        """Mostly for testing varLib.main()
249        """
250        suffix = '.ttf'
251        ds_path = self.get_test_input('Build.designspace')
252        ttx_dir = self.get_test_input('master_ttx_interpolatable_ttf')
253
254        self.temp_dir()
255        ttf_dir = os.path.join(self.tempdir, 'master_ttf_interpolatable')
256        os.makedirs(ttf_dir)
257        ttx_paths = self.get_file_list(ttx_dir, '.ttx', 'TestFamily-')
258        for path in ttx_paths:
259            self.compile_font(path, suffix, ttf_dir)
260
261        ds_copy = os.path.join(self.tempdir, 'BuildMain.designspace')
262        shutil.copy2(ds_path, ds_copy)
263
264        # by default, varLib.main finds master TTFs inside a
265        # 'master_ttf_interpolatable' subfolder in current working dir
266        cwd = os.getcwd()
267        os.chdir(self.tempdir)
268        try:
269            varLib_main([ds_copy])
270        finally:
271            os.chdir(cwd)
272
273        varfont_path = os.path.splitext(ds_copy)[0] + '-VF' + suffix
274        self.assertTrue(os.path.exists(varfont_path))
275
276        # try again passing an explicit --master-finder
277        os.remove(varfont_path)
278        finder = "%s/master_ttf_interpolatable/{stem}.ttf" % self.tempdir
279        varLib_main([ds_copy, "--master-finder", finder])
280        self.assertTrue(os.path.exists(varfont_path))
281
282        # and also with explicit -o output option
283        os.remove(varfont_path)
284        varfont_path = os.path.splitext(varfont_path)[0] + "-o" + suffix
285        varLib_main([ds_copy, "-o", varfont_path, "--master-finder", finder])
286        self.assertTrue(os.path.exists(varfont_path))
287
288        varfont = TTFont(varfont_path)
289        tables = [table_tag for table_tag in varfont.keys() if table_tag != 'head']
290        expected_ttx_path = self.get_test_output('BuildMain.ttx')
291        self.expect_ttx(varfont, expected_ttx_path, tables)
292
293    def test_varlib_build_from_ds_object_in_memory_ttfonts(self):
294        ds_path = self.get_test_input("Build.designspace")
295        ttx_dir = self.get_test_input("master_ttx_interpolatable_ttf")
296        expected_ttx_path = self.get_test_output("BuildMain.ttx")
297
298        self.temp_dir()
299        for path in self.get_file_list(ttx_dir, '.ttx', 'TestFamily-'):
300            self.compile_font(path, ".ttf", self.tempdir)
301
302        ds = DesignSpaceDocument.fromfile(ds_path)
303        for source in ds.sources:
304            filename = os.path.join(
305                self.tempdir, os.path.basename(source.filename).replace(".ufo", ".ttf")
306            )
307            source.font = TTFont(
308                filename, recalcBBoxes=False, recalcTimestamp=False, lazy=True
309            )
310            source.filename = None  # Make sure no file path gets into build()
311
312        varfont, _, _ = build(ds)
313        varfont = reload_font(varfont)
314        tables = [table_tag for table_tag in varfont.keys() if table_tag != "head"]
315        self.expect_ttx(varfont, expected_ttx_path, tables)
316
317    def test_varlib_build_from_ttf_paths(self):
318        ds_path = self.get_test_input("Build.designspace")
319        ttx_dir = self.get_test_input("master_ttx_interpolatable_ttf")
320        expected_ttx_path = self.get_test_output("BuildMain.ttx")
321
322        self.temp_dir()
323        for path in self.get_file_list(ttx_dir, '.ttx', 'TestFamily-'):
324            self.compile_font(path, ".ttf", self.tempdir)
325
326        ds = DesignSpaceDocument.fromfile(ds_path)
327        for source in ds.sources:
328            source.path = os.path.join(
329                self.tempdir, os.path.basename(source.filename).replace(".ufo", ".ttf")
330            )
331        ds.updatePaths()
332
333        varfont, _, _ = build(ds)
334        varfont = reload_font(varfont)
335        tables = [table_tag for table_tag in varfont.keys() if table_tag != "head"]
336        self.expect_ttx(varfont, expected_ttx_path, tables)
337
338    def test_varlib_build_from_ttx_paths(self):
339        ds_path = self.get_test_input("Build.designspace")
340        ttx_dir = self.get_test_input("master_ttx_interpolatable_ttf")
341        expected_ttx_path = self.get_test_output("BuildMain.ttx")
342
343        ds = DesignSpaceDocument.fromfile(ds_path)
344        for source in ds.sources:
345            source.path = os.path.join(
346                ttx_dir, os.path.basename(source.filename).replace(".ufo", ".ttx")
347            )
348        ds.updatePaths()
349
350        varfont, _, _ = build(ds)
351        varfont = reload_font(varfont)
352        tables = [table_tag for table_tag in varfont.keys() if table_tag != "head"]
353        self.expect_ttx(varfont, expected_ttx_path, tables)
354
355    def test_varlib_build_sparse_masters(self):
356        ds_path = self.get_test_input("SparseMasters.designspace")
357        expected_ttx_path = self.get_test_output("SparseMasters.ttx")
358
359        varfont, _, _ = build(ds_path)
360        varfont = reload_font(varfont)
361        tables = [table_tag for table_tag in varfont.keys() if table_tag != "head"]
362        self.expect_ttx(varfont, expected_ttx_path, tables)
363
364    def test_varlib_build_sparse_masters_MVAR(self):
365        import fontTools.varLib.mvar
366
367        ds_path = self.get_test_input("SparseMasters.designspace")
368        ds = DesignSpaceDocument.fromfile(ds_path)
369        load_masters(ds)
370
371        # Trigger MVAR generation so varLib is forced to create deltas with a
372        # sparse master inbetween.
373        font_0_os2 = ds.sources[0].font["OS/2"]
374        font_0_os2.sTypoAscender = 1
375        font_0_os2.sTypoDescender = 1
376        font_0_os2.sTypoLineGap = 1
377        font_0_os2.usWinAscent = 1
378        font_0_os2.usWinDescent = 1
379        font_0_os2.sxHeight = 1
380        font_0_os2.sCapHeight = 1
381        font_0_os2.ySubscriptXSize = 1
382        font_0_os2.ySubscriptYSize = 1
383        font_0_os2.ySubscriptXOffset = 1
384        font_0_os2.ySubscriptYOffset = 1
385        font_0_os2.ySuperscriptXSize = 1
386        font_0_os2.ySuperscriptYSize = 1
387        font_0_os2.ySuperscriptXOffset = 1
388        font_0_os2.ySuperscriptYOffset = 1
389        font_0_os2.yStrikeoutSize = 1
390        font_0_os2.yStrikeoutPosition = 1
391        font_0_vhea = newTable("vhea")
392        font_0_vhea.ascent = 1
393        font_0_vhea.descent = 1
394        font_0_vhea.lineGap = 1
395        font_0_vhea.caretSlopeRise = 1
396        font_0_vhea.caretSlopeRun = 1
397        font_0_vhea.caretOffset = 1
398        ds.sources[0].font["vhea"] = font_0_vhea
399        font_0_hhea = ds.sources[0].font["hhea"]
400        font_0_hhea.caretSlopeRise = 1
401        font_0_hhea.caretSlopeRun = 1
402        font_0_hhea.caretOffset = 1
403        font_0_post = ds.sources[0].font["post"]
404        font_0_post.underlineThickness = 1
405        font_0_post.underlinePosition = 1
406
407        font_2_os2 = ds.sources[2].font["OS/2"]
408        font_2_os2.sTypoAscender = 800
409        font_2_os2.sTypoDescender = 800
410        font_2_os2.sTypoLineGap = 800
411        font_2_os2.usWinAscent = 800
412        font_2_os2.usWinDescent = 800
413        font_2_os2.sxHeight = 800
414        font_2_os2.sCapHeight = 800
415        font_2_os2.ySubscriptXSize = 800
416        font_2_os2.ySubscriptYSize = 800
417        font_2_os2.ySubscriptXOffset = 800
418        font_2_os2.ySubscriptYOffset = 800
419        font_2_os2.ySuperscriptXSize = 800
420        font_2_os2.ySuperscriptYSize = 800
421        font_2_os2.ySuperscriptXOffset = 800
422        font_2_os2.ySuperscriptYOffset = 800
423        font_2_os2.yStrikeoutSize = 800
424        font_2_os2.yStrikeoutPosition = 800
425        font_2_vhea = newTable("vhea")
426        font_2_vhea.ascent = 800
427        font_2_vhea.descent = 800
428        font_2_vhea.lineGap = 800
429        font_2_vhea.caretSlopeRise = 800
430        font_2_vhea.caretSlopeRun = 800
431        font_2_vhea.caretOffset = 800
432        ds.sources[2].font["vhea"] = font_2_vhea
433        font_2_hhea = ds.sources[2].font["hhea"]
434        font_2_hhea.caretSlopeRise = 800
435        font_2_hhea.caretSlopeRun = 800
436        font_2_hhea.caretOffset = 800
437        font_2_post = ds.sources[2].font["post"]
438        font_2_post.underlineThickness = 800
439        font_2_post.underlinePosition = 800
440
441        varfont, _, _ = build(ds)
442        mvar_tags = [vr.ValueTag for vr in varfont["MVAR"].table.ValueRecord]
443        assert all(tag in mvar_tags for tag in fontTools.varLib.mvar.MVAR_ENTRIES)
444
445
446def test_load_masters_layerName_without_required_font():
447    ds = DesignSpaceDocument()
448    s = SourceDescriptor()
449    s.font = None
450    s.layerName = "Medium"
451    ds.addSource(s)
452
453    with pytest.raises(
454        AttributeError,
455        match="specified a layer name but lacks the required TTFont object",
456    ):
457        load_masters(ds)
458
459
460if __name__ == "__main__":
461    sys.exit(unittest.main())
462