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