xref: /aosp_15_r20/external/fonttools/Tests/designspaceLib/designspace_test.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
1# coding=utf-8
2
3import os
4from pathlib import Path
5import re
6import shutil
7
8import pytest
9from fontTools import ttLib
10from fontTools.designspaceLib import (
11    AxisDescriptor,
12    AxisMappingDescriptor,
13    AxisLabelDescriptor,
14    DesignSpaceDocument,
15    DesignSpaceDocumentError,
16    DiscreteAxisDescriptor,
17    InstanceDescriptor,
18    RuleDescriptor,
19    SourceDescriptor,
20    evaluateRule,
21    posix,
22    processRules,
23)
24from fontTools.designspaceLib.types import Range
25from fontTools.misc import plistlib
26
27from .fixtures import datadir
28
29
30def _axesAsDict(axes):
31    """
32    Make the axis data we have available in
33    """
34    axesDict = {}
35    for axisDescriptor in axes:
36        d = {
37            "name": axisDescriptor.name,
38            "tag": axisDescriptor.tag,
39            "minimum": axisDescriptor.minimum,
40            "maximum": axisDescriptor.maximum,
41            "default": axisDescriptor.default,
42            "map": axisDescriptor.map,
43        }
44        axesDict[axisDescriptor.name] = d
45    return axesDict
46
47
48def assert_equals_test_file(path, test_filename):
49    with open(path, encoding="utf-8") as fp:
50        actual = fp.read()
51
52    test_path = os.path.join(os.path.dirname(__file__), test_filename)
53    with open(test_path, encoding="utf-8") as fp:
54        expected = fp.read()
55        expected = re.sub(r"<!--(.|\n)*?-->", "", expected)
56        expected = re.sub(r"\s*\n+", "\n", expected)
57
58    assert actual == expected
59
60
61def test_fill_document(tmpdir):
62    tmpdir = str(tmpdir)
63    testDocPath = os.path.join(tmpdir, "test_v4.designspace")
64    testDocPath5 = os.path.join(tmpdir, "test_v5.designspace")
65    masterPath1 = os.path.join(tmpdir, "masters", "masterTest1.ufo")
66    masterPath2 = os.path.join(tmpdir, "masters", "masterTest2.ufo")
67    instancePath1 = os.path.join(tmpdir, "instances", "instanceTest1.ufo")
68    instancePath2 = os.path.join(tmpdir, "instances", "instanceTest2.ufo")
69    doc = DesignSpaceDocument()
70    doc.rulesProcessingLast = True
71
72    # write some axes
73    a1 = AxisDescriptor()
74    a1.minimum = 0
75    a1.maximum = 1000
76    a1.default = 0
77    a1.name = "weight"
78    a1.tag = "wght"
79    # note: just to test the element language, not an actual label name recommendations.
80    a1.labelNames["fa-IR"] = "قطر"
81    a1.labelNames["en"] = "Wéíght"
82    doc.addAxis(a1)
83    a2 = AxisDescriptor()
84    a2.minimum = 0
85    a2.maximum = 1000
86    a2.default = 15
87    a2.name = "width"
88    a2.tag = "wdth"
89    a2.map = [(0.0, 10.0), (15.0, 20.0), (401.0, 66.0), (1000.0, 990.0)]
90    a2.hidden = True
91    a2.labelNames["fr"] = "Chasse"
92    doc.addAxis(a2)
93
94    # add master 1
95    s1 = SourceDescriptor()
96    s1.filename = os.path.relpath(masterPath1, os.path.dirname(testDocPath))
97    assert s1.font is None
98    s1.name = "master.ufo1"
99    s1.copyLib = True
100    s1.copyInfo = True
101    s1.copyFeatures = True
102    s1.location = dict(weight=0)
103    s1.familyName = "MasterFamilyName"
104    s1.styleName = "MasterStyleNameOne"
105    s1.mutedGlyphNames.append("A")
106    s1.mutedGlyphNames.append("Z")
107    doc.addSource(s1)
108    # add master 2
109    s2 = SourceDescriptor()
110    s2.filename = os.path.relpath(masterPath2, os.path.dirname(testDocPath))
111    s2.name = "master.ufo2"
112    s2.copyLib = False
113    s2.copyInfo = False
114    s2.copyFeatures = False
115    s2.muteKerning = True
116    s2.location = dict(weight=1000)
117    s2.familyName = "MasterFamilyName"
118    s2.styleName = "MasterStyleNameTwo"
119    doc.addSource(s2)
120    # add master 3 from a different layer
121    s3 = SourceDescriptor()
122    s3.filename = os.path.relpath(masterPath2, os.path.dirname(testDocPath))
123    s3.name = "master.ufo2"
124    s3.copyLib = False
125    s3.copyInfo = False
126    s3.copyFeatures = False
127    s3.muteKerning = False
128    s3.layerName = "supports"
129    s3.location = dict(weight=1000)
130    s3.familyName = "MasterFamilyName"
131    s3.styleName = "Supports"
132    doc.addSource(s3)
133    # add instance 1
134    i1 = InstanceDescriptor()
135    i1.filename = os.path.relpath(instancePath1, os.path.dirname(testDocPath))
136    i1.familyName = "InstanceFamilyName"
137    i1.styleName = "InstanceStyleName"
138    i1.name = "instance.ufo1"
139    i1.location = dict(
140        weight=500, spooky=666
141    )  # this adds a dimension that is not defined.
142    i1.postScriptFontName = "InstancePostscriptName"
143    i1.styleMapFamilyName = "InstanceStyleMapFamilyName"
144    i1.styleMapStyleName = "InstanceStyleMapStyleName"
145    i1.localisedStyleName = dict(fr="Demigras", ja="半ば")
146    i1.localisedFamilyName = dict(fr="Montserrat", ja="モンセラート")
147    i1.localisedStyleMapStyleName = dict(de="Standard")
148    i1.localisedStyleMapFamilyName = dict(
149        de="Montserrat Halbfett", ja="モンセラート SemiBold"
150    )
151    glyphData = dict(name="arrow", mute=True, unicodes=[0x123, 0x124, 0x125])
152    i1.glyphs["arrow"] = glyphData
153    i1.lib["com.coolDesignspaceApp.binaryData"] = plistlib.Data(b"<binary gunk>")
154    i1.lib["com.coolDesignspaceApp.specimenText"] = "Hamburgerwhatever"
155    doc.addInstance(i1)
156    # add instance 2
157    i2 = InstanceDescriptor()
158    i2.filename = os.path.relpath(instancePath2, os.path.dirname(testDocPath))
159    i2.familyName = "InstanceFamilyName"
160    i2.styleName = "InstanceStyleName"
161    i2.name = "instance.ufo2"
162    # anisotropic location
163    i2.location = dict(weight=500, width=(400, 300))
164    i2.postScriptFontName = "InstancePostscriptName"
165    i2.styleMapFamilyName = "InstanceStyleMapFamilyName"
166    i2.styleMapStyleName = "InstanceStyleMapStyleName"
167    glyphMasters = [
168        dict(font="master.ufo1", glyphName="BB", location=dict(width=20, weight=20)),
169        dict(font="master.ufo2", glyphName="CC", location=dict(width=900, weight=900)),
170    ]
171    glyphData = dict(name="arrow", unicodes=[101, 201, 301])
172    glyphData["masters"] = glyphMasters
173    glyphData["note"] = "A note about this glyph"
174    glyphData["instanceLocation"] = dict(width=100, weight=120)
175    i2.glyphs["arrow"] = glyphData
176    i2.glyphs["arrow2"] = dict(mute=False)
177    doc.addInstance(i2)
178
179    doc.filename = "suggestedFileName.designspace"
180    doc.lib["com.coolDesignspaceApp.previewSize"] = 30
181
182    # write some rules
183    r1 = RuleDescriptor()
184    r1.name = "named.rule.1"
185    r1.conditionSets.append(
186        [
187            dict(name="axisName_a", minimum=0, maximum=1),
188            dict(name="axisName_b", minimum=2, maximum=3),
189        ]
190    )
191    r1.subs.append(("a", "a.alt"))
192    doc.addRule(r1)
193    # write the document; without an explicit format it will be 5.0 by default
194    doc.write(testDocPath5)
195    assert os.path.exists(testDocPath5)
196    assert_equals_test_file(testDocPath5, "data/test_v5_original.designspace")
197    # write again with an explicit format = 4.1
198    doc.formatVersion = "4.1"
199    doc.write(testDocPath)
200    assert os.path.exists(testDocPath)
201    assert_equals_test_file(testDocPath, "data/test_v4_original.designspace")
202    # import it again
203    new = DesignSpaceDocument()
204    new.read(testDocPath)
205
206    assert new.default.location == {"width": 20.0, "weight": 0.0}
207    assert new.filename == "test_v4.designspace"
208    assert new.lib == doc.lib
209    assert new.instances[0].lib == doc.instances[0].lib
210
211    # test roundtrip for the axis attributes and data
212    axes = {}
213    for axis in doc.axes:
214        if axis.tag not in axes:
215            axes[axis.tag] = []
216        axes[axis.tag].append(axis.serialize())
217    for axis in new.axes:
218        if axis.tag[0] == "_":
219            continue
220        if axis.tag not in axes:
221            axes[axis.tag] = []
222        axes[axis.tag].append(axis.serialize())
223    for v in axes.values():
224        a, b = v
225        assert a == b
226
227
228def test_unicodes(tmpdir):
229    tmpdir = str(tmpdir)
230    testDocPath = os.path.join(tmpdir, "testUnicodes.designspace")
231    testDocPath2 = os.path.join(tmpdir, "testUnicodes_roundtrip.designspace")
232    masterPath1 = os.path.join(tmpdir, "masters", "masterTest1.ufo")
233    masterPath2 = os.path.join(tmpdir, "masters", "masterTest2.ufo")
234    instancePath1 = os.path.join(tmpdir, "instances", "instanceTest1.ufo")
235    instancePath2 = os.path.join(tmpdir, "instances", "instanceTest2.ufo")
236    doc = DesignSpaceDocument()
237    doc.formatVersion = "4.1"  # This test about instance glyphs is deprecated in v5
238    # add master 1
239    s1 = SourceDescriptor()
240    s1.filename = os.path.relpath(masterPath1, os.path.dirname(testDocPath))
241    s1.name = "master.ufo1"
242    s1.copyInfo = True
243    s1.location = dict(weight=0)
244    doc.addSource(s1)
245    # add master 2
246    s2 = SourceDescriptor()
247    s2.filename = os.path.relpath(masterPath2, os.path.dirname(testDocPath))
248    s2.name = "master.ufo2"
249    s2.location = dict(weight=1000)
250    doc.addSource(s2)
251    # add instance 1
252    i1 = InstanceDescriptor()
253    i1.filename = os.path.relpath(instancePath1, os.path.dirname(testDocPath))
254    i1.name = "instance.ufo1"
255    i1.location = dict(weight=500)
256    glyphData = dict(name="arrow", mute=True, unicodes=[100, 200, 300])
257    i1.glyphs["arrow"] = glyphData
258    doc.addInstance(i1)
259    # now we have sources and instances, but no axes yet.
260    doc.axes = []  # clear the axes
261    # write some axes
262    a1 = AxisDescriptor()
263    a1.minimum = 0
264    a1.maximum = 1000
265    a1.default = 0
266    a1.name = "weight"
267    a1.tag = "wght"
268    doc.addAxis(a1)
269    # write the document
270    doc.write(testDocPath)
271    assert os.path.exists(testDocPath)
272    # import it again
273    new = DesignSpaceDocument()
274    new.read(testDocPath)
275    new.write(testDocPath2)
276    # compare the file contents
277    with open(testDocPath, "r", encoding="utf-8") as f1:
278        t1 = f1.read()
279    with open(testDocPath2, "r", encoding="utf-8") as f2:
280        t2 = f2.read()
281    assert t1 == t2
282    # check the unicode values read from the document
283    assert new.instances[0].glyphs["arrow"]["unicodes"] == [100, 200, 300]
284
285
286def test_localisedNames(tmpdir):
287    tmpdir = str(tmpdir)
288    testDocPath = os.path.join(tmpdir, "testLocalisedNames.designspace")
289    testDocPath2 = os.path.join(tmpdir, "testLocalisedNames_roundtrip.designspace")
290    masterPath1 = os.path.join(tmpdir, "masters", "masterTest1.ufo")
291    masterPath2 = os.path.join(tmpdir, "masters", "masterTest2.ufo")
292    instancePath1 = os.path.join(tmpdir, "instances", "instanceTest1.ufo")
293    instancePath2 = os.path.join(tmpdir, "instances", "instanceTest2.ufo")
294    doc = DesignSpaceDocument()
295    # add master 1
296    s1 = SourceDescriptor()
297    s1.filename = os.path.relpath(masterPath1, os.path.dirname(testDocPath))
298    s1.name = "master.ufo1"
299    s1.copyInfo = True
300    s1.location = dict(weight=0)
301    doc.addSource(s1)
302    # add master 2
303    s2 = SourceDescriptor()
304    s2.filename = os.path.relpath(masterPath2, os.path.dirname(testDocPath))
305    s2.name = "master.ufo2"
306    s2.location = dict(weight=1000)
307    doc.addSource(s2)
308    # add instance 1
309    i1 = InstanceDescriptor()
310    i1.filename = os.path.relpath(instancePath1, os.path.dirname(testDocPath))
311    i1.familyName = "Montserrat"
312    i1.styleName = "SemiBold"
313    i1.styleMapFamilyName = "Montserrat SemiBold"
314    i1.styleMapStyleName = "Regular"
315    i1.setFamilyName("Montserrat", "fr")
316    i1.setFamilyName("モンセラート", "ja")
317    i1.setStyleName("Demigras", "fr")
318    i1.setStyleName("半ば", "ja")
319    i1.setStyleMapStyleName("Standard", "de")
320    i1.setStyleMapFamilyName("Montserrat Halbfett", "de")
321    i1.setStyleMapFamilyName("モンセラート SemiBold", "ja")
322    i1.name = "instance.ufo1"
323    i1.location = dict(
324        weight=500, spooky=666
325    )  # this adds a dimension that is not defined.
326    i1.postScriptFontName = "InstancePostscriptName"
327    glyphData = dict(name="arrow", mute=True, unicodes=[0x123])
328    i1.glyphs["arrow"] = glyphData
329    doc.addInstance(i1)
330    # now we have sources and instances, but no axes yet.
331    doc.axes = []  # clear the axes
332    # write some axes
333    a1 = AxisDescriptor()
334    a1.minimum = 0
335    a1.maximum = 1000
336    a1.default = 0
337    a1.name = "weight"
338    a1.tag = "wght"
339    # note: just to test the element language, not an actual label name recommendations.
340    a1.labelNames["fa-IR"] = "قطر"
341    a1.labelNames["en"] = "Wéíght"
342    doc.addAxis(a1)
343    a2 = AxisDescriptor()
344    a2.minimum = 0
345    a2.maximum = 1000
346    a2.default = 0
347    a2.name = "width"
348    a2.tag = "wdth"
349    a2.map = [(0.0, 10.0), (401.0, 66.0), (1000.0, 990.0)]
350    a2.labelNames["fr"] = "Poids"
351    doc.addAxis(a2)
352    # add an axis that is not part of any location to see if that works
353    a3 = AxisDescriptor()
354    a3.minimum = 333
355    a3.maximum = 666
356    a3.default = 444
357    a3.name = "spooky"
358    a3.tag = "spok"
359    a3.map = [(0.0, 10.0), (401.0, 66.0), (1000.0, 990.0)]
360    # doc.addAxis(a3)    # uncomment this line to test the effects of default axes values
361    # write some rules
362    r1 = RuleDescriptor()
363    r1.name = "named.rule.1"
364    r1.conditionSets.append(
365        [
366            dict(name="weight", minimum=200, maximum=500),
367            dict(name="width", minimum=0, maximum=150),
368        ]
369    )
370    r1.subs.append(("a", "a.alt"))
371    doc.addRule(r1)
372    # write the document
373    doc.write(testDocPath)
374    assert os.path.exists(testDocPath)
375    # import it again
376    new = DesignSpaceDocument()
377    new.read(testDocPath)
378    new.write(testDocPath2)
379    with open(testDocPath, "r", encoding="utf-8") as f1:
380        t1 = f1.read()
381    with open(testDocPath2, "r", encoding="utf-8") as f2:
382        t2 = f2.read()
383    assert t1 == t2
384
385
386def test_handleNoAxes(tmpdir):
387    tmpdir = str(tmpdir)
388    # test what happens if the designspacedocument has no axes element.
389    testDocPath = os.path.join(tmpdir, "testNoAxes_source.designspace")
390    testDocPath2 = os.path.join(tmpdir, "testNoAxes_recontructed.designspace")
391    masterPath1 = os.path.join(tmpdir, "masters", "masterTest1.ufo")
392    masterPath2 = os.path.join(tmpdir, "masters", "masterTest2.ufo")
393    instancePath1 = os.path.join(tmpdir, "instances", "instanceTest1.ufo")
394    instancePath2 = os.path.join(tmpdir, "instances", "instanceTest2.ufo")
395
396    # Case 1: No axes element in the document, but there are sources and instances
397    doc = DesignSpaceDocument()
398
399    for name, value in [("One", 1), ("Two", 2), ("Three", 3)]:
400        a = AxisDescriptor()
401        a.minimum = 0
402        a.maximum = 1000
403        a.default = 0
404        a.name = "axisName%s" % (name)
405        a.tag = "ax_%d" % (value)
406        doc.addAxis(a)
407
408    # add master 1
409    s1 = SourceDescriptor()
410    s1.filename = os.path.relpath(masterPath1, os.path.dirname(testDocPath))
411    s1.name = "master.ufo1"
412    s1.copyLib = True
413    s1.copyInfo = True
414    s1.copyFeatures = True
415    s1.location = dict(axisNameOne=-1000, axisNameTwo=0, axisNameThree=1000)
416    s1.familyName = "MasterFamilyName"
417    s1.styleName = "MasterStyleNameOne"
418    doc.addSource(s1)
419
420    # add master 2
421    s2 = SourceDescriptor()
422    s2.filename = os.path.relpath(masterPath2, os.path.dirname(testDocPath))
423    s2.name = "master.ufo1"
424    s2.copyLib = False
425    s2.copyInfo = False
426    s2.copyFeatures = False
427    s2.location = dict(axisNameOne=1000, axisNameTwo=1000, axisNameThree=0)
428    s2.familyName = "MasterFamilyName"
429    s2.styleName = "MasterStyleNameTwo"
430    doc.addSource(s2)
431
432    # add instance 1
433    i1 = InstanceDescriptor()
434    i1.filename = os.path.relpath(instancePath1, os.path.dirname(testDocPath))
435    i1.familyName = "InstanceFamilyName"
436    i1.styleName = "InstanceStyleName"
437    i1.name = "instance.ufo1"
438    i1.location = dict(axisNameOne=(-1000, 500), axisNameTwo=100)
439    i1.postScriptFontName = "InstancePostscriptName"
440    i1.styleMapFamilyName = "InstanceStyleMapFamilyName"
441    i1.styleMapStyleName = "InstanceStyleMapStyleName"
442    doc.addInstance(i1)
443
444    doc.write(testDocPath)
445    verify = DesignSpaceDocument()
446    verify.read(testDocPath)
447    verify.write(testDocPath2)
448
449
450def test_pathNameResolve(tmpdir):
451    tmpdir = str(tmpdir)
452    # test how descriptor.path and descriptor.filename are resolved
453    testDocPath1 = os.path.join(tmpdir, "testPathName_case1.designspace")
454    testDocPath2 = os.path.join(tmpdir, "testPathName_case2.designspace")
455    testDocPath3 = os.path.join(tmpdir, "testPathName_case3.designspace")
456    testDocPath4 = os.path.join(tmpdir, "testPathName_case4.designspace")
457    testDocPath5 = os.path.join(tmpdir, "testPathName_case5.designspace")
458    testDocPath6 = os.path.join(tmpdir, "testPathName_case6.designspace")
459    masterPath1 = os.path.join(tmpdir, "masters", "masterTest1.ufo")
460    masterPath2 = os.path.join(tmpdir, "masters", "masterTest2.ufo")
461    instancePath1 = os.path.join(tmpdir, "instances", "instanceTest1.ufo")
462    instancePath2 = os.path.join(tmpdir, "instances", "instanceTest2.ufo")
463
464    a1 = AxisDescriptor()
465    a1.tag = "TAGA"
466    a1.name = "axisName_a"
467    a1.minimum = 0
468    a1.maximum = 1000
469    a1.default = 0
470
471    # Case 1: filename and path are both empty. Nothing to calculate, nothing to put in the file.
472    doc = DesignSpaceDocument()
473    doc.addAxis(a1)
474    s = SourceDescriptor()
475    s.filename = None
476    s.path = None
477    s.copyInfo = True
478    s.location = dict(weight=0)
479    s.familyName = "MasterFamilyName"
480    s.styleName = "MasterStyleNameOne"
481    doc.addSource(s)
482    doc.write(testDocPath1)
483    verify = DesignSpaceDocument()
484    verify.read(testDocPath1)
485    assert verify.sources[0].filename == None
486    assert verify.sources[0].path == None
487
488    # Case 2: filename is empty, path points somewhere: calculate a new filename.
489    doc = DesignSpaceDocument()
490    doc.addAxis(a1)
491    s = SourceDescriptor()
492    s.filename = None
493    s.path = masterPath1
494    s.copyInfo = True
495    s.location = dict(weight=0)
496    s.familyName = "MasterFamilyName"
497    s.styleName = "MasterStyleNameOne"
498    doc.addSource(s)
499    doc.write(testDocPath2)
500    verify = DesignSpaceDocument()
501    verify.read(testDocPath2)
502    assert verify.sources[0].filename == "masters/masterTest1.ufo"
503    assert verify.sources[0].path == posix(masterPath1)
504
505    # Case 3: the filename is set, the path is None.
506    doc = DesignSpaceDocument()
507    doc.addAxis(a1)
508    s = SourceDescriptor()
509    s.filename = "../somewhere/over/the/rainbow.ufo"
510    s.path = None
511    s.copyInfo = True
512    s.location = dict(weight=0)
513    s.familyName = "MasterFamilyName"
514    s.styleName = "MasterStyleNameOne"
515    doc.addSource(s)
516    doc.write(testDocPath3)
517    verify = DesignSpaceDocument()
518    verify.read(testDocPath3)
519    assert verify.sources[0].filename == "../somewhere/over/the/rainbow.ufo"
520    # make the absolute path for filename so we can see if it matches the path
521    p = os.path.abspath(
522        os.path.join(os.path.dirname(testDocPath3), verify.sources[0].filename)
523    )
524    assert verify.sources[0].path == posix(p)
525
526    # Case 4: the filename points to one file, the path points to another. The path takes precedence.
527    doc = DesignSpaceDocument()
528    doc.addAxis(a1)
529    s = SourceDescriptor()
530    s.filename = "../somewhere/over/the/rainbow.ufo"
531    s.path = masterPath1
532    s.copyInfo = True
533    s.location = dict(weight=0)
534    s.familyName = "MasterFamilyName"
535    s.styleName = "MasterStyleNameOne"
536    doc.addSource(s)
537    doc.write(testDocPath4)
538    verify = DesignSpaceDocument()
539    verify.read(testDocPath4)
540    assert verify.sources[0].filename == "masters/masterTest1.ufo"
541
542    # Case 5: the filename is None, path has a value, update the filename
543    doc = DesignSpaceDocument()
544    doc.addAxis(a1)
545    s = SourceDescriptor()
546    s.filename = None
547    s.path = masterPath1
548    s.copyInfo = True
549    s.location = dict(weight=0)
550    s.familyName = "MasterFamilyName"
551    s.styleName = "MasterStyleNameOne"
552    doc.addSource(s)
553    doc.write(testDocPath5)  # so that the document has a path
554    doc.updateFilenameFromPath()
555    assert doc.sources[0].filename == "masters/masterTest1.ufo"
556
557    # Case 6: the filename has a value, path has a value, update the filenames with force
558    doc = DesignSpaceDocument()
559    doc.addAxis(a1)
560    s = SourceDescriptor()
561    s.filename = "../somewhere/over/the/rainbow.ufo"
562    s.path = masterPath1
563    s.copyInfo = True
564    s.location = dict(weight=0)
565    s.familyName = "MasterFamilyName"
566    s.styleName = "MasterStyleNameOne"
567    doc.write(testDocPath5)  # so that the document has a path
568    doc.addSource(s)
569    assert doc.sources[0].filename == "../somewhere/over/the/rainbow.ufo"
570    doc.updateFilenameFromPath(force=True)
571    assert doc.sources[0].filename == "masters/masterTest1.ufo"
572
573
574def test_normalise1():
575    # normalisation of anisotropic locations, clipping
576    doc = DesignSpaceDocument()
577    # write some axes
578    a1 = AxisDescriptor()
579    a1.minimum = -1000
580    a1.maximum = 1000
581    a1.default = 0
582    a1.name = "axisName_a"
583    a1.tag = "TAGA"
584    doc.addAxis(a1)
585    assert doc.normalizeLocation(dict(axisName_a=0)) == {"axisName_a": 0.0}
586    assert doc.normalizeLocation(dict(axisName_a=1000)) == {"axisName_a": 1.0}
587    # clipping beyond max values:
588    assert doc.normalizeLocation(dict(axisName_a=1001)) == {"axisName_a": 1.0}
589    assert doc.normalizeLocation(dict(axisName_a=500)) == {"axisName_a": 0.5}
590    assert doc.normalizeLocation(dict(axisName_a=-1000)) == {"axisName_a": -1.0}
591    assert doc.normalizeLocation(dict(axisName_a=-1001)) == {"axisName_a": -1.0}
592    # anisotropic coordinates normalise to isotropic
593    assert doc.normalizeLocation(dict(axisName_a=(1000, -1000))) == {"axisName_a": 1.0}
594    doc.normalize()
595    r = []
596    for axis in doc.axes:
597        r.append((axis.name, axis.minimum, axis.default, axis.maximum))
598    r.sort()
599    assert r == [("axisName_a", -1.0, 0.0, 1.0)]
600
601
602def test_normalise2():
603    # normalisation with minimum > 0
604    doc = DesignSpaceDocument()
605    # write some axes
606    a2 = AxisDescriptor()
607    a2.minimum = 100
608    a2.maximum = 1000
609    a2.default = 100
610    a2.name = "axisName_b"
611    doc.addAxis(a2)
612    assert doc.normalizeLocation(dict(axisName_b=0)) == {"axisName_b": 0.0}
613    assert doc.normalizeLocation(dict(axisName_b=1000)) == {"axisName_b": 1.0}
614    # clipping beyond max values:
615    assert doc.normalizeLocation(dict(axisName_b=1001)) == {"axisName_b": 1.0}
616    assert doc.normalizeLocation(dict(axisName_b=500)) == {
617        "axisName_b": 0.4444444444444444
618    }
619    assert doc.normalizeLocation(dict(axisName_b=-1000)) == {"axisName_b": 0.0}
620    assert doc.normalizeLocation(dict(axisName_b=-1001)) == {"axisName_b": 0.0}
621    # anisotropic coordinates normalise to isotropic
622    assert doc.normalizeLocation(dict(axisName_b=(1000, -1000))) == {"axisName_b": 1.0}
623    assert doc.normalizeLocation(dict(axisName_b=1001)) == {"axisName_b": 1.0}
624    doc.normalize()
625    r = []
626    for axis in doc.axes:
627        r.append((axis.name, axis.minimum, axis.default, axis.maximum))
628    r.sort()
629    assert r == [("axisName_b", 0.0, 0.0, 1.0)]
630
631
632def test_normalise3():
633    # normalisation of negative values, with default == maximum
634    doc = DesignSpaceDocument()
635    # write some axes
636    a3 = AxisDescriptor()
637    a3.minimum = -1000
638    a3.maximum = 0
639    a3.default = 0
640    a3.name = "ccc"
641    doc.addAxis(a3)
642    assert doc.normalizeLocation(dict(ccc=0)) == {"ccc": 0.0}
643    assert doc.normalizeLocation(dict(ccc=1)) == {"ccc": 0.0}
644    assert doc.normalizeLocation(dict(ccc=-1000)) == {"ccc": -1.0}
645    assert doc.normalizeLocation(dict(ccc=-1001)) == {"ccc": -1.0}
646    doc.normalize()
647    r = []
648    for axis in doc.axes:
649        r.append((axis.name, axis.minimum, axis.default, axis.maximum))
650    r.sort()
651    assert r == [("ccc", -1.0, 0.0, 0.0)]
652
653
654def test_normalise4():
655    # normalisation with a map
656    doc = DesignSpaceDocument()
657    # write some axes
658    a4 = AxisDescriptor()
659    a4.minimum = 0
660    a4.maximum = 1000
661    a4.default = 0
662    a4.name = "ddd"
663    a4.map = [(0, 100), (300, 500), (600, 500), (1000, 900)]
664    doc.addAxis(a4)
665    doc.normalize()
666    r = []
667    for axis in doc.axes:
668        r.append((axis.name, axis.map))
669    r.sort()
670    assert r == [("ddd", [(0, 0.0), (300, 0.5), (600, 0.5), (1000, 1.0)])]
671
672
673def test_axisMapping():
674    # note: because designspance lib does not do any actual
675    # processing of the mapping data, we can only check if there data is there.
676    doc = DesignSpaceDocument()
677    # write some axes
678    a4 = AxisDescriptor()
679    a4.minimum = 0
680    a4.maximum = 1000
681    a4.default = 0
682    a4.name = "ddd"
683    a4.map = [(0, 100), (300, 500), (600, 500), (1000, 900)]
684    doc.addAxis(a4)
685    doc.normalize()
686    r = []
687    for axis in doc.axes:
688        r.append((axis.name, axis.map))
689    r.sort()
690    assert r == [("ddd", [(0, 0.0), (300, 0.5), (600, 0.5), (1000, 1.0)])]
691
692
693def test_axisMappingsRoundtrip(tmpdir):
694    # tests of axisMappings in a document, roundtripping.
695
696    tmpdir = str(tmpdir)
697    srcDocPath = (Path(__file__) / "../data/test_avar2.designspace").resolve()
698    testDocPath = os.path.join(tmpdir, "test_avar2.designspace")
699    shutil.copy(srcDocPath, testDocPath)
700    testDocPath2 = os.path.join(tmpdir, "test_avar2_roundtrip.designspace")
701    doc = DesignSpaceDocument()
702    doc.read(testDocPath)
703    assert doc.axisMappings
704    assert len(doc.axisMappings) == 2
705    assert doc.axisMappings[0].inputLocation == {"Justify": -100.0, "Width": 100.0}
706
707    # This is a bit of a hack, but it's the only way to make sure
708    # that the save works on Windows if the tempdir and the data
709    # dir are on different drives.
710    for descriptor in doc.sources + doc.instances:
711        descriptor.path = None
712
713    doc.write(testDocPath2)
714    # verify these results
715    doc2 = DesignSpaceDocument()
716    doc2.read(testDocPath2)
717    assert [mapping.inputLocation for mapping in doc.axisMappings] == [
718        mapping.inputLocation for mapping in doc2.axisMappings
719    ]
720    assert [mapping.outputLocation for mapping in doc.axisMappings] == [
721        mapping.outputLocation for mapping in doc2.axisMappings
722    ]
723    assert [mapping.description for mapping in doc.axisMappings] == [
724        mapping.description for mapping in doc2.axisMappings
725    ]
726    assert [mapping.groupDescription for mapping in doc.axisMappings] == [
727        mapping.groupDescription for mapping in doc2.axisMappings
728    ]
729
730
731def test_rulesConditions(tmpdir):
732    # tests of rules, conditionsets and conditions
733    r1 = RuleDescriptor()
734    r1.name = "named.rule.1"
735    r1.conditionSets.append(
736        [
737            dict(name="axisName_a", minimum=0, maximum=1000),
738            dict(name="axisName_b", minimum=0, maximum=3000),
739        ]
740    )
741    r1.subs.append(("a", "a.alt"))
742
743    assert evaluateRule(r1, dict(axisName_a=500, axisName_b=0)) == True
744    assert evaluateRule(r1, dict(axisName_a=0, axisName_b=0)) == True
745    assert evaluateRule(r1, dict(axisName_a=1000, axisName_b=0)) == True
746    assert evaluateRule(r1, dict(axisName_a=1000, axisName_b=-100)) == False
747    assert evaluateRule(r1, dict(axisName_a=1000.0001, axisName_b=0)) == False
748    assert evaluateRule(r1, dict(axisName_a=-0.0001, axisName_b=0)) == False
749    assert evaluateRule(r1, dict(axisName_a=-100, axisName_b=0)) == False
750    assert processRules([r1], dict(axisName_a=500, axisName_b=0), ["a", "b", "c"]) == [
751        "a.alt",
752        "b",
753        "c",
754    ]
755    assert processRules(
756        [r1], dict(axisName_a=500, axisName_b=0), ["a.alt", "b", "c"]
757    ) == ["a.alt", "b", "c"]
758    assert processRules([r1], dict(axisName_a=2000, axisName_b=0), ["a", "b", "c"]) == [
759        "a",
760        "b",
761        "c",
762    ]
763
764    # rule with only a maximum
765    r2 = RuleDescriptor()
766    r2.name = "named.rule.2"
767    r2.conditionSets.append([dict(name="axisName_a", maximum=500)])
768    r2.subs.append(("b", "b.alt"))
769
770    assert evaluateRule(r2, dict(axisName_a=0)) == True
771    assert evaluateRule(r2, dict(axisName_a=-500)) == True
772    assert evaluateRule(r2, dict(axisName_a=1000)) == False
773
774    # rule with only a minimum
775    r3 = RuleDescriptor()
776    r3.name = "named.rule.3"
777    r3.conditionSets.append([dict(name="axisName_a", minimum=500)])
778    r3.subs.append(("c", "c.alt"))
779
780    assert evaluateRule(r3, dict(axisName_a=0)) == False
781    assert evaluateRule(r3, dict(axisName_a=1000)) == True
782    assert evaluateRule(r3, dict(axisName_a=1000)) == True
783
784    # rule with only a minimum, maximum in separate conditions
785    r4 = RuleDescriptor()
786    r4.name = "named.rule.4"
787    r4.conditionSets.append(
788        [dict(name="axisName_a", minimum=500), dict(name="axisName_b", maximum=500)]
789    )
790    r4.subs.append(("c", "c.alt"))
791
792    assert evaluateRule(r4, dict(axisName_a=1000, axisName_b=0)) == True
793    assert evaluateRule(r4, dict(axisName_a=0, axisName_b=0)) == False
794    assert evaluateRule(r4, dict(axisName_a=1000, axisName_b=1000)) == False
795
796
797def test_rulesDocument(tmpdir):
798    # tests of rules in a document, roundtripping.
799    tmpdir = str(tmpdir)
800    testDocPath = os.path.join(tmpdir, "testRules.designspace")
801    testDocPath2 = os.path.join(tmpdir, "testRules_roundtrip.designspace")
802    doc = DesignSpaceDocument()
803    doc.rulesProcessingLast = True
804    a1 = AxisDescriptor()
805    a1.minimum = 0
806    a1.maximum = 1000
807    a1.default = 0
808    a1.name = "axisName_a"
809    a1.tag = "TAGA"
810    b1 = AxisDescriptor()
811    b1.minimum = 2000
812    b1.maximum = 3000
813    b1.default = 2000
814    b1.name = "axisName_b"
815    b1.tag = "TAGB"
816    doc.addAxis(a1)
817    doc.addAxis(b1)
818    r1 = RuleDescriptor()
819    r1.name = "named.rule.1"
820    r1.conditionSets.append(
821        [
822            dict(name="axisName_a", minimum=0, maximum=1000),
823            dict(name="axisName_b", minimum=0, maximum=3000),
824        ]
825    )
826    r1.subs.append(("a", "a.alt"))
827    # rule with minium and maximum
828    doc.addRule(r1)
829    assert len(doc.rules) == 1
830    assert len(doc.rules[0].conditionSets) == 1
831    assert len(doc.rules[0].conditionSets[0]) == 2
832    assert _axesAsDict(doc.axes) == {
833        "axisName_a": {
834            "map": [],
835            "name": "axisName_a",
836            "default": 0,
837            "minimum": 0,
838            "maximum": 1000,
839            "tag": "TAGA",
840        },
841        "axisName_b": {
842            "map": [],
843            "name": "axisName_b",
844            "default": 2000,
845            "minimum": 2000,
846            "maximum": 3000,
847            "tag": "TAGB",
848        },
849    }
850    assert doc.rules[0].conditionSets == [
851        [
852            {"minimum": 0, "maximum": 1000, "name": "axisName_a"},
853            {"minimum": 0, "maximum": 3000, "name": "axisName_b"},
854        ]
855    ]
856    assert doc.rules[0].subs == [("a", "a.alt")]
857    doc.normalize()
858    assert doc.rules[0].name == "named.rule.1"
859    assert doc.rules[0].conditionSets == [
860        [
861            {"minimum": 0.0, "maximum": 1.0, "name": "axisName_a"},
862            {"minimum": 0.0, "maximum": 1.0, "name": "axisName_b"},
863        ]
864    ]
865    # still one conditionset
866    assert len(doc.rules[0].conditionSets) == 1
867    doc.write(testDocPath)
868    # add a stray conditionset
869    _addUnwrappedCondition(testDocPath)
870    doc2 = DesignSpaceDocument()
871    doc2.read(testDocPath)
872    assert doc2.rulesProcessingLast
873    assert len(doc2.axes) == 2
874    assert len(doc2.rules) == 1
875    assert len(doc2.rules[0].conditionSets) == 2
876    doc2.write(testDocPath2)
877    # verify these results
878    # make sure the stray condition is now neatly wrapped in a conditionset.
879    doc3 = DesignSpaceDocument()
880    doc3.read(testDocPath2)
881    assert len(doc3.rules) == 1
882    assert len(doc3.rules[0].conditionSets) == 2
883
884
885def _addUnwrappedCondition(path):
886    # only for testing, so we can make an invalid designspace file
887    # older designspace files may have conditions that are not wrapped in a conditionset
888    # These can be read into a new conditionset.
889    with open(path, "r", encoding="utf-8") as f:
890        d = f.read()
891    print(d)
892    d = d.replace(
893        '<rule name="named.rule.1">',
894        '<rule name="named.rule.1">\n\t<condition maximum="22" minimum="33" name="axisName_a" />',
895    )
896    with open(path, "w", encoding="utf-8") as f:
897        f.write(d)
898
899
900def test_documentLib(tmpdir):
901    # roundtrip test of the document lib with some nested data
902    tmpdir = str(tmpdir)
903    testDocPath1 = os.path.join(tmpdir, "testDocumentLibTest.designspace")
904    doc = DesignSpaceDocument()
905    a1 = AxisDescriptor()
906    a1.tag = "TAGA"
907    a1.name = "axisName_a"
908    a1.minimum = 0
909    a1.maximum = 1000
910    a1.default = 0
911    doc.addAxis(a1)
912    dummyData = dict(a=123, b="äbc", c=[1, 2, 3], d={"a": 123})
913    dummyKey = "org.fontTools.designspaceLib"
914    doc.lib = {dummyKey: dummyData}
915    doc.write(testDocPath1)
916    new = DesignSpaceDocument()
917    new.read(testDocPath1)
918    assert dummyKey in new.lib
919    assert new.lib[dummyKey] == dummyData
920
921
922def test_updatePaths(tmpdir):
923    doc = DesignSpaceDocument()
924    doc.path = str(tmpdir / "foo" / "bar" / "MyDesignspace.designspace")
925
926    s1 = SourceDescriptor()
927    doc.addSource(s1)
928
929    doc.updatePaths()
930
931    # expect no changes
932    assert s1.path is None
933    assert s1.filename is None
934
935    name1 = "../masters/Source1.ufo"
936    path1 = posix(str(tmpdir / "foo" / "masters" / "Source1.ufo"))
937
938    s1.path = path1
939    s1.filename = None
940
941    doc.updatePaths()
942
943    assert s1.path == path1
944    assert s1.filename == name1  # empty filename updated
945
946    name2 = "../masters/Source2.ufo"
947    s1.filename = name2
948
949    doc.updatePaths()
950
951    # conflicting filename discarded, path always gets precedence
952    assert s1.path == path1
953    assert s1.filename == "../masters/Source1.ufo"
954
955    s1.path = None
956    s1.filename = name2
957
958    doc.updatePaths()
959
960    # expect no changes
961    assert s1.path is None
962    assert s1.filename == name2
963
964
965def test_read_with_path_object():
966    source = (Path(__file__) / "../data/test_v4_original.designspace").resolve()
967    assert source.exists()
968    doc = DesignSpaceDocument()
969    doc.read(source)
970
971
972def test_with_with_path_object(tmpdir):
973    tmpdir = str(tmpdir)
974    dest = Path(tmpdir) / "test_v4_original.designspace"
975    doc = DesignSpaceDocument()
976    doc.write(dest)
977    assert dest.exists()
978
979
980def test_findDefault_axis_mapping():
981    designspace_string = """\
982<?xml version='1.0' encoding='UTF-8'?>
983<designspace format="4.0">
984  <axes>
985    <axis tag="wght" name="Weight" minimum="100" maximum="800" default="400">
986      <map input="100" output="20"/>
987      <map input="300" output="40"/>
988      <map input="400" output="80"/>
989      <map input="700" output="126"/>
990      <map input="800" output="170"/>
991    </axis>
992    <axis tag="ital" name="Italic" minimum="0" maximum="1" default="1"/>
993  </axes>
994  <sources>
995    <source filename="Font-Light.ufo">
996      <location>
997        <dimension name="Weight" xvalue="20"/>
998        <dimension name="Italic" xvalue="0"/>
999      </location>
1000    </source>
1001    <source filename="Font-Regular.ufo">
1002      <location>
1003        <dimension name="Weight" xvalue="80"/>
1004        <dimension name="Italic" xvalue="0"/>
1005      </location>
1006    </source>
1007    <source filename="Font-Bold.ufo">
1008      <location>
1009        <dimension name="Weight" xvalue="170"/>
1010        <dimension name="Italic" xvalue="0"/>
1011      </location>
1012    </source>
1013    <source filename="Font-LightItalic.ufo">
1014      <location>
1015        <dimension name="Weight" xvalue="20"/>
1016        <dimension name="Italic" xvalue="1"/>
1017      </location>
1018    </source>
1019    <source filename="Font-Italic.ufo">
1020      <location>
1021        <dimension name="Weight" xvalue="80"/>
1022        <dimension name="Italic" xvalue="1"/>
1023      </location>
1024    </source>
1025    <source filename="Font-BoldItalic.ufo">
1026      <location>
1027        <dimension name="Weight" xvalue="170"/>
1028        <dimension name="Italic" xvalue="1"/>
1029      </location>
1030    </source>
1031  </sources>
1032</designspace>
1033    """
1034    designspace = DesignSpaceDocument.fromstring(designspace_string)
1035    assert designspace.findDefault().filename == "Font-Italic.ufo"
1036
1037    designspace.axes[1].default = 0
1038
1039    assert designspace.findDefault().filename == "Font-Regular.ufo"
1040
1041
1042def test_loadSourceFonts():
1043    def opener(path):
1044        font = ttLib.TTFont()
1045        font.importXML(path)
1046        return font
1047
1048    # this designspace file contains .TTX source paths
1049    path = os.path.join(
1050        os.path.dirname(os.path.dirname(__file__)),
1051        "varLib",
1052        "data",
1053        "SparseMasters.designspace",
1054    )
1055    designspace = DesignSpaceDocument.fromfile(path)
1056
1057    # force two source descriptors to have the same path
1058    designspace.sources[1].path = designspace.sources[0].path
1059
1060    fonts = designspace.loadSourceFonts(opener)
1061
1062    assert len(fonts) == 3
1063    assert all(isinstance(font, ttLib.TTFont) for font in fonts)
1064    assert fonts[0] is fonts[1]  # same path, identical font object
1065
1066    fonts2 = designspace.loadSourceFonts(opener)
1067
1068    for font1, font2 in zip(fonts, fonts2):
1069        assert font1 is font2
1070
1071
1072def test_loadSourceFonts_no_required_path():
1073    designspace = DesignSpaceDocument()
1074    designspace.sources.append(SourceDescriptor())
1075
1076    with pytest.raises(DesignSpaceDocumentError, match="no 'path' attribute"):
1077        designspace.loadSourceFonts(lambda p: p)
1078
1079
1080def test_addAxisDescriptor():
1081    ds = DesignSpaceDocument()
1082
1083    axis = ds.addAxisDescriptor(
1084        name="Weight", tag="wght", minimum=100, default=400, maximum=900
1085    )
1086
1087    assert ds.axes[0] is axis
1088    assert isinstance(axis, AxisDescriptor)
1089    assert axis.name == "Weight"
1090    assert axis.tag == "wght"
1091    assert axis.minimum == 100
1092    assert axis.default == 400
1093    assert axis.maximum == 900
1094
1095
1096def test_addAxisDescriptor():
1097    ds = DesignSpaceDocument()
1098
1099    mapping = ds.addAxisMappingDescriptor(
1100        inputLocation={"weight": 900, "width": 150}, outputLocation={"weight": 870}
1101    )
1102
1103    assert ds.axisMappings[0] is mapping
1104    assert isinstance(mapping, AxisMappingDescriptor)
1105    assert mapping.inputLocation == {"weight": 900, "width": 150}
1106    assert mapping.outputLocation == {"weight": 870}
1107
1108
1109def test_addSourceDescriptor():
1110    ds = DesignSpaceDocument()
1111
1112    source = ds.addSourceDescriptor(name="TestSource", location={"Weight": 400})
1113
1114    assert ds.sources[0] is source
1115    assert isinstance(source, SourceDescriptor)
1116    assert source.name == "TestSource"
1117    assert source.location == {"Weight": 400}
1118
1119
1120def test_addInstanceDescriptor():
1121    ds = DesignSpaceDocument()
1122
1123    instance = ds.addInstanceDescriptor(
1124        name="TestInstance",
1125        location={"Weight": 400},
1126        styleName="Regular",
1127        styleMapStyleName="regular",
1128    )
1129
1130    assert ds.instances[0] is instance
1131    assert isinstance(instance, InstanceDescriptor)
1132    assert instance.name == "TestInstance"
1133    assert instance.location == {"Weight": 400}
1134    assert instance.styleName == "Regular"
1135    assert instance.styleMapStyleName == "regular"
1136
1137
1138def test_addRuleDescriptor(tmp_path):
1139    ds = DesignSpaceDocument()
1140
1141    rule = ds.addRuleDescriptor(
1142        name="TestRule",
1143        conditionSets=[
1144            [
1145                dict(name="Weight", minimum=100, maximum=200),
1146                dict(name="Weight", minimum=700, maximum=900),
1147            ]
1148        ],
1149        subs=[("a", "a.alt")],
1150    )
1151
1152    assert ds.rules[0] is rule
1153    assert isinstance(rule, RuleDescriptor)
1154    assert rule.name == "TestRule"
1155    assert rule.conditionSets == [
1156        [
1157            dict(name="Weight", minimum=100, maximum=200),
1158            dict(name="Weight", minimum=700, maximum=900),
1159        ]
1160    ]
1161    assert rule.subs == [("a", "a.alt")]
1162
1163    # Test it doesn't crash.
1164    ds.write(tmp_path / "test.designspace")
1165
1166
1167def test_deepcopyExceptFonts():
1168    ds = DesignSpaceDocument()
1169    ds.addSourceDescriptor(font=object())
1170    ds.addSourceDescriptor(font=object())
1171
1172    ds_copy = ds.deepcopyExceptFonts()
1173
1174    assert ds.tostring() == ds_copy.tostring()
1175    assert ds.sources[0].font is ds_copy.sources[0].font
1176    assert ds.sources[1].font is ds_copy.sources[1].font
1177
1178
1179def test_Range_post_init():
1180    # test min and max are sorted and default is clamped to either min/max
1181    r = Range(minimum=2, maximum=-1, default=-2)
1182    assert r.minimum == -1
1183    assert r.maximum == 2
1184    assert r.default == -1
1185
1186
1187def test_get_axes(datadir: Path) -> None:
1188    ds = DesignSpaceDocument.fromfile(datadir / "test_v5.designspace")
1189
1190    assert ds.getAxis("Width") is ds.getAxisByTag("wdth")
1191    assert ds.getAxis("Italic") is ds.getAxisByTag("ital")
1192