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