Coverage for /usr/lib/python3/dist-packages/fontTools/designspaceLib/__init__.py: 15%

1559 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-14 15:55 +0200

1from __future__ import annotations 

2 

3import collections 

4import copy 

5import itertools 

6import math 

7import os 

8import posixpath 

9from io import BytesIO, StringIO 

10from textwrap import indent 

11from typing import Any, Dict, List, MutableMapping, Optional, Tuple, Union, cast 

12 

13from fontTools.misc import etree as ET 

14from fontTools.misc import plistlib 

15from fontTools.misc.loggingTools import LogMixin 

16from fontTools.misc.textTools import tobytes, tostr 

17 

18""" 

19 designSpaceDocument 

20 

21 - read and write designspace files 

22""" 

23 

24__all__ = [ 

25 "AxisDescriptor", 

26 "AxisLabelDescriptor", 

27 "AxisMappingDescriptor", 

28 "BaseDocReader", 

29 "BaseDocWriter", 

30 "DesignSpaceDocument", 

31 "DesignSpaceDocumentError", 

32 "DiscreteAxisDescriptor", 

33 "InstanceDescriptor", 

34 "LocationLabelDescriptor", 

35 "RangeAxisSubsetDescriptor", 

36 "RuleDescriptor", 

37 "SourceDescriptor", 

38 "ValueAxisSubsetDescriptor", 

39 "VariableFontDescriptor", 

40] 

41 

42# ElementTree allows to find namespace-prefixed elements, but not attributes 

43# so we have to do it ourselves for 'xml:lang' 

44XML_NS = "{http://www.w3.org/XML/1998/namespace}" 

45XML_LANG = XML_NS + "lang" 

46 

47 

48def posix(path): 

49 """Normalize paths using forward slash to work also on Windows.""" 

50 new_path = posixpath.join(*path.split(os.path.sep)) 

51 if path.startswith("/"): 

52 # The above transformation loses absolute paths 

53 new_path = "/" + new_path 

54 elif path.startswith(r"\\"): 

55 # The above transformation loses leading slashes of UNC path mounts 

56 new_path = "//" + new_path 

57 return new_path 

58 

59 

60def posixpath_property(private_name): 

61 """Generate a propery that holds a path always using forward slashes.""" 

62 

63 def getter(self): 

64 # Normal getter 

65 return getattr(self, private_name) 

66 

67 def setter(self, value): 

68 # The setter rewrites paths using forward slashes 

69 if value is not None: 

70 value = posix(value) 

71 setattr(self, private_name, value) 

72 

73 return property(getter, setter) 

74 

75 

76class DesignSpaceDocumentError(Exception): 

77 def __init__(self, msg, obj=None): 

78 self.msg = msg 

79 self.obj = obj 

80 

81 def __str__(self): 

82 return str(self.msg) + (": %r" % self.obj if self.obj is not None else "") 

83 

84 

85class AsDictMixin(object): 

86 def asdict(self): 

87 d = {} 

88 for attr, value in self.__dict__.items(): 

89 if attr.startswith("_"): 

90 continue 

91 if hasattr(value, "asdict"): 

92 value = value.asdict() 

93 elif isinstance(value, list): 

94 value = [v.asdict() if hasattr(v, "asdict") else v for v in value] 

95 d[attr] = value 

96 return d 

97 

98 

99class SimpleDescriptor(AsDictMixin): 

100 """Containers for a bunch of attributes""" 

101 

102 # XXX this is ugly. The 'print' is inappropriate here, and instead of 

103 # assert, it should simply return True/False 

104 def compare(self, other): 

105 # test if this object contains the same data as the other 

106 for attr in self._attrs: 

107 try: 

108 assert getattr(self, attr) == getattr(other, attr) 

109 except AssertionError: 

110 print( 

111 "failed attribute", 

112 attr, 

113 getattr(self, attr), 

114 "!=", 

115 getattr(other, attr), 

116 ) 

117 

118 def __repr__(self): 

119 attrs = [f"{a}={repr(getattr(self, a))}," for a in self._attrs] 

120 attrs = indent("\n".join(attrs), " ") 

121 return f"{self.__class__.__name__}(\n{attrs}\n)" 

122 

123 

124class SourceDescriptor(SimpleDescriptor): 

125 """Simple container for data related to the source 

126 

127 .. code:: python 

128 

129 doc = DesignSpaceDocument() 

130 s1 = SourceDescriptor() 

131 s1.path = masterPath1 

132 s1.name = "master.ufo1" 

133 s1.font = defcon.Font("master.ufo1") 

134 s1.location = dict(weight=0) 

135 s1.familyName = "MasterFamilyName" 

136 s1.styleName = "MasterStyleNameOne" 

137 s1.localisedFamilyName = dict(fr="Caractère") 

138 s1.mutedGlyphNames.append("A") 

139 s1.mutedGlyphNames.append("Z") 

140 doc.addSource(s1) 

141 

142 """ 

143 

144 flavor = "source" 

145 _attrs = [ 

146 "filename", 

147 "path", 

148 "name", 

149 "layerName", 

150 "location", 

151 "copyLib", 

152 "copyGroups", 

153 "copyFeatures", 

154 "muteKerning", 

155 "muteInfo", 

156 "mutedGlyphNames", 

157 "familyName", 

158 "styleName", 

159 "localisedFamilyName", 

160 ] 

161 

162 filename = posixpath_property("_filename") 

163 path = posixpath_property("_path") 

164 

165 def __init__( 

166 self, 

167 *, 

168 filename=None, 

169 path=None, 

170 font=None, 

171 name=None, 

172 location=None, 

173 designLocation=None, 

174 layerName=None, 

175 familyName=None, 

176 styleName=None, 

177 localisedFamilyName=None, 

178 copyLib=False, 

179 copyInfo=False, 

180 copyGroups=False, 

181 copyFeatures=False, 

182 muteKerning=False, 

183 muteInfo=False, 

184 mutedGlyphNames=None, 

185 ): 

186 self.filename = filename 

187 """string. A relative path to the source file, **as it is in the document**. 

188 

189 MutatorMath + VarLib. 

190 """ 

191 self.path = path 

192 """The absolute path, calculated from filename.""" 

193 

194 self.font = font 

195 """Any Python object. Optional. Points to a representation of this 

196 source font that is loaded in memory, as a Python object (e.g. a 

197 ``defcon.Font`` or a ``fontTools.ttFont.TTFont``). 

198 

199 The default document reader will not fill-in this attribute, and the 

200 default writer will not use this attribute. It is up to the user of 

201 ``designspaceLib`` to either load the resource identified by 

202 ``filename`` and store it in this field, or write the contents of 

203 this field to the disk and make ```filename`` point to that. 

204 """ 

205 

206 self.name = name 

207 """string. Optional. Unique identifier name for this source. 

208 

209 MutatorMath + varLib. 

210 """ 

211 

212 self.designLocation = ( 

213 designLocation if designLocation is not None else location or {} 

214 ) 

215 """dict. Axis values for this source, in design space coordinates. 

216 

217 MutatorMath + varLib. 

218 

219 This may be only part of the full design location. 

220 See :meth:`getFullDesignLocation()` 

221 

222 .. versionadded:: 5.0 

223 """ 

224 

225 self.layerName = layerName 

226 """string. The name of the layer in the source to look for 

227 outline data. Default ``None`` which means ``foreground``. 

228 """ 

229 self.familyName = familyName 

230 """string. Family name of this source. Though this data 

231 can be extracted from the font, it can be efficient to have it right 

232 here. 

233 

234 varLib. 

235 """ 

236 self.styleName = styleName 

237 """string. Style name of this source. Though this data 

238 can be extracted from the font, it can be efficient to have it right 

239 here. 

240 

241 varLib. 

242 """ 

243 self.localisedFamilyName = localisedFamilyName or {} 

244 """dict. A dictionary of localised family name strings, keyed by 

245 language code. 

246 

247 If present, will be used to build localized names for all instances. 

248 

249 .. versionadded:: 5.0 

250 """ 

251 

252 self.copyLib = copyLib 

253 """bool. Indicates if the contents of the font.lib need to 

254 be copied to the instances. 

255 

256 MutatorMath. 

257 

258 .. deprecated:: 5.0 

259 """ 

260 self.copyInfo = copyInfo 

261 """bool. Indicates if the non-interpolating font.info needs 

262 to be copied to the instances. 

263 

264 MutatorMath. 

265 

266 .. deprecated:: 5.0 

267 """ 

268 self.copyGroups = copyGroups 

269 """bool. Indicates if the groups need to be copied to the 

270 instances. 

271 

272 MutatorMath. 

273 

274 .. deprecated:: 5.0 

275 """ 

276 self.copyFeatures = copyFeatures 

277 """bool. Indicates if the feature text needs to be 

278 copied to the instances. 

279 

280 MutatorMath. 

281 

282 .. deprecated:: 5.0 

283 """ 

284 self.muteKerning = muteKerning 

285 """bool. Indicates if the kerning data from this source 

286 needs to be muted (i.e. not be part of the calculations). 

287 

288 MutatorMath only. 

289 """ 

290 self.muteInfo = muteInfo 

291 """bool. Indicated if the interpolating font.info data for 

292 this source needs to be muted. 

293 

294 MutatorMath only. 

295 """ 

296 self.mutedGlyphNames = mutedGlyphNames or [] 

297 """list. Glyphnames that need to be muted in the 

298 instances. 

299 

300 MutatorMath only. 

301 """ 

302 

303 @property 

304 def location(self): 

305 """dict. Axis values for this source, in design space coordinates. 

306 

307 MutatorMath + varLib. 

308 

309 .. deprecated:: 5.0 

310 Use the more explicit alias for this property :attr:`designLocation`. 

311 """ 

312 return self.designLocation 

313 

314 @location.setter 

315 def location(self, location: Optional[AnisotropicLocationDict]): 

316 self.designLocation = location or {} 

317 

318 def setFamilyName(self, familyName, languageCode="en"): 

319 """Setter for :attr:`localisedFamilyName` 

320 

321 .. versionadded:: 5.0 

322 """ 

323 self.localisedFamilyName[languageCode] = tostr(familyName) 

324 

325 def getFamilyName(self, languageCode="en"): 

326 """Getter for :attr:`localisedFamilyName` 

327 

328 .. versionadded:: 5.0 

329 """ 

330 return self.localisedFamilyName.get(languageCode) 

331 

332 def getFullDesignLocation( 

333 self, doc: "DesignSpaceDocument" 

334 ) -> AnisotropicLocationDict: 

335 """Get the complete design location of this source, from its 

336 :attr:`designLocation` and the document's axis defaults. 

337 

338 .. versionadded:: 5.0 

339 """ 

340 result: AnisotropicLocationDict = {} 

341 for axis in doc.axes: 

342 if axis.name in self.designLocation: 

343 result[axis.name] = self.designLocation[axis.name] 

344 else: 

345 result[axis.name] = axis.map_forward(axis.default) 

346 return result 

347 

348 

349class RuleDescriptor(SimpleDescriptor): 

350 """Represents the rule descriptor element: a set of glyph substitutions to 

351 trigger conditionally in some parts of the designspace. 

352 

353 .. code:: python 

354 

355 r1 = RuleDescriptor() 

356 r1.name = "unique.rule.name" 

357 r1.conditionSets.append([dict(name="weight", minimum=-10, maximum=10), dict(...)]) 

358 r1.conditionSets.append([dict(...), dict(...)]) 

359 r1.subs.append(("a", "a.alt")) 

360 

361 .. code:: xml 

362 

363 <!-- optional: list of substitution rules --> 

364 <rules> 

365 <rule name="vertical.bars"> 

366 <conditionset> 

367 <condition minimum="250.000000" maximum="750.000000" name="weight"/> 

368 <condition minimum="100" name="width"/> 

369 <condition minimum="10" maximum="40" name="optical"/> 

370 </conditionset> 

371 <sub name="cent" with="cent.alt"/> 

372 <sub name="dollar" with="dollar.alt"/> 

373 </rule> 

374 </rules> 

375 """ 

376 

377 _attrs = ["name", "conditionSets", "subs"] # what do we need here 

378 

379 def __init__(self, *, name=None, conditionSets=None, subs=None): 

380 self.name = name 

381 """string. Unique name for this rule. Can be used to reference this rule data.""" 

382 # list of lists of dict(name='aaaa', minimum=0, maximum=1000) 

383 self.conditionSets = conditionSets or [] 

384 """a list of conditionsets. 

385 

386 - Each conditionset is a list of conditions. 

387 - Each condition is a dict with ``name``, ``minimum`` and ``maximum`` keys. 

388 """ 

389 # list of substitutions stored as tuples of glyphnames ("a", "a.alt") 

390 self.subs = subs or [] 

391 """list of substitutions. 

392 

393 - Each substitution is stored as tuples of glyphnames, e.g. ("a", "a.alt"). 

394 - Note: By default, rules are applied first, before other text 

395 shaping/OpenType layout, as they are part of the 

396 `Required Variation Alternates OpenType feature <https://docs.microsoft.com/en-us/typography/opentype/spec/features_pt#-tag-rvrn>`_. 

397 See ref:`rules-element` § Attributes. 

398 """ 

399 

400 

401def evaluateRule(rule, location): 

402 """Return True if any of the rule's conditionsets matches the given location.""" 

403 return any(evaluateConditions(c, location) for c in rule.conditionSets) 

404 

405 

406def evaluateConditions(conditions, location): 

407 """Return True if all the conditions matches the given location. 

408 

409 - If a condition has no minimum, check for < maximum. 

410 - If a condition has no maximum, check for > minimum. 

411 """ 

412 for cd in conditions: 

413 value = location[cd["name"]] 

414 if cd.get("minimum") is None: 

415 if value > cd["maximum"]: 

416 return False 

417 elif cd.get("maximum") is None: 

418 if cd["minimum"] > value: 

419 return False 

420 elif not cd["minimum"] <= value <= cd["maximum"]: 

421 return False 

422 return True 

423 

424 

425def processRules(rules, location, glyphNames): 

426 """Apply these rules at this location to these glyphnames. 

427 

428 Return a new list of glyphNames with substitutions applied. 

429 

430 - rule order matters 

431 """ 

432 newNames = [] 

433 for rule in rules: 

434 if evaluateRule(rule, location): 

435 for name in glyphNames: 

436 swap = False 

437 for a, b in rule.subs: 

438 if name == a: 

439 swap = True 

440 break 

441 if swap: 

442 newNames.append(b) 

443 else: 

444 newNames.append(name) 

445 glyphNames = newNames 

446 newNames = [] 

447 return glyphNames 

448 

449 

450AnisotropicLocationDict = Dict[str, Union[float, Tuple[float, float]]] 

451SimpleLocationDict = Dict[str, float] 

452 

453 

454class AxisMappingDescriptor(SimpleDescriptor): 

455 """Represents the axis mapping element: mapping an input location 

456 to an output location in the designspace. 

457 

458 .. code:: python 

459 

460 m1 = AxisMappingDescriptor() 

461 m1.inputLocation = {"weight": 900, "width": 150} 

462 m1.outputLocation = {"weight": 870} 

463 

464 .. code:: xml 

465 

466 <mappings> 

467 <mapping> 

468 <input> 

469 <dimension name="weight" xvalue="900"/> 

470 <dimension name="width" xvalue="150"/> 

471 </input> 

472 <output> 

473 <dimension name="weight" xvalue="870"/> 

474 </output> 

475 </mapping> 

476 </mappings> 

477 """ 

478 

479 _attrs = ["inputLocation", "outputLocation"] 

480 

481 def __init__(self, *, inputLocation=None, outputLocation=None): 

482 self.inputLocation: SimpleLocationDict = inputLocation or {} 

483 """dict. Axis values for the input of the mapping, in design space coordinates. 

484 

485 varLib. 

486 

487 .. versionadded:: 5.1 

488 """ 

489 self.outputLocation: SimpleLocationDict = outputLocation or {} 

490 """dict. Axis values for the output of the mapping, in design space coordinates. 

491 

492 varLib. 

493 

494 .. versionadded:: 5.1 

495 """ 

496 

497 

498class InstanceDescriptor(SimpleDescriptor): 

499 """Simple container for data related to the instance 

500 

501 

502 .. code:: python 

503 

504 i2 = InstanceDescriptor() 

505 i2.path = instancePath2 

506 i2.familyName = "InstanceFamilyName" 

507 i2.styleName = "InstanceStyleName" 

508 i2.name = "instance.ufo2" 

509 # anisotropic location 

510 i2.designLocation = dict(weight=500, width=(400,300)) 

511 i2.postScriptFontName = "InstancePostscriptName" 

512 i2.styleMapFamilyName = "InstanceStyleMapFamilyName" 

513 i2.styleMapStyleName = "InstanceStyleMapStyleName" 

514 i2.lib['com.coolDesignspaceApp.specimenText'] = 'Hamburgerwhatever' 

515 doc.addInstance(i2) 

516 """ 

517 

518 flavor = "instance" 

519 _defaultLanguageCode = "en" 

520 _attrs = [ 

521 "filename", 

522 "path", 

523 "name", 

524 "locationLabel", 

525 "designLocation", 

526 "userLocation", 

527 "familyName", 

528 "styleName", 

529 "postScriptFontName", 

530 "styleMapFamilyName", 

531 "styleMapStyleName", 

532 "localisedFamilyName", 

533 "localisedStyleName", 

534 "localisedStyleMapFamilyName", 

535 "localisedStyleMapStyleName", 

536 "glyphs", 

537 "kerning", 

538 "info", 

539 "lib", 

540 ] 

541 

542 filename = posixpath_property("_filename") 

543 path = posixpath_property("_path") 

544 

545 def __init__( 

546 self, 

547 *, 

548 filename=None, 

549 path=None, 

550 font=None, 

551 name=None, 

552 location=None, 

553 locationLabel=None, 

554 designLocation=None, 

555 userLocation=None, 

556 familyName=None, 

557 styleName=None, 

558 postScriptFontName=None, 

559 styleMapFamilyName=None, 

560 styleMapStyleName=None, 

561 localisedFamilyName=None, 

562 localisedStyleName=None, 

563 localisedStyleMapFamilyName=None, 

564 localisedStyleMapStyleName=None, 

565 glyphs=None, 

566 kerning=True, 

567 info=True, 

568 lib=None, 

569 ): 

570 self.filename = filename 

571 """string. Relative path to the instance file, **as it is 

572 in the document**. The file may or may not exist. 

573 

574 MutatorMath + VarLib. 

575 """ 

576 self.path = path 

577 """string. Absolute path to the instance file, calculated from 

578 the document path and the string in the filename attr. The file may 

579 or may not exist. 

580 

581 MutatorMath. 

582 """ 

583 self.font = font 

584 """Same as :attr:`SourceDescriptor.font` 

585 

586 .. seealso:: :attr:`SourceDescriptor.font` 

587 """ 

588 self.name = name 

589 """string. Unique identifier name of the instance, used to 

590 identify it if it needs to be referenced from elsewhere in the 

591 document. 

592 """ 

593 self.locationLabel = locationLabel 

594 """Name of a :class:`LocationLabelDescriptor`. If 

595 provided, the instance should have the same location as the 

596 LocationLabel. 

597 

598 .. seealso:: 

599 :meth:`getFullDesignLocation` 

600 :meth:`getFullUserLocation` 

601 

602 .. versionadded:: 5.0 

603 """ 

604 self.designLocation: AnisotropicLocationDict = ( 

605 designLocation if designLocation is not None else (location or {}) 

606 ) 

607 """dict. Axis values for this instance, in design space coordinates. 

608 

609 MutatorMath + varLib. 

610 

611 .. seealso:: This may be only part of the full location. See: 

612 :meth:`getFullDesignLocation` 

613 :meth:`getFullUserLocation` 

614 

615 .. versionadded:: 5.0 

616 """ 

617 self.userLocation: SimpleLocationDict = userLocation or {} 

618 """dict. Axis values for this instance, in user space coordinates. 

619 

620 MutatorMath + varLib. 

621 

622 .. seealso:: This may be only part of the full location. See: 

623 :meth:`getFullDesignLocation` 

624 :meth:`getFullUserLocation` 

625 

626 .. versionadded:: 5.0 

627 """ 

628 self.familyName = familyName 

629 """string. Family name of this instance. 

630 

631 MutatorMath + varLib. 

632 """ 

633 self.styleName = styleName 

634 """string. Style name of this instance. 

635 

636 MutatorMath + varLib. 

637 """ 

638 self.postScriptFontName = postScriptFontName 

639 """string. Postscript fontname for this instance. 

640 

641 MutatorMath + varLib. 

642 """ 

643 self.styleMapFamilyName = styleMapFamilyName 

644 """string. StyleMap familyname for this instance. 

645 

646 MutatorMath + varLib. 

647 """ 

648 self.styleMapStyleName = styleMapStyleName 

649 """string. StyleMap stylename for this instance. 

650 

651 MutatorMath + varLib. 

652 """ 

653 self.localisedFamilyName = localisedFamilyName or {} 

654 """dict. A dictionary of localised family name 

655 strings, keyed by language code. 

656 """ 

657 self.localisedStyleName = localisedStyleName or {} 

658 """dict. A dictionary of localised stylename 

659 strings, keyed by language code. 

660 """ 

661 self.localisedStyleMapFamilyName = localisedStyleMapFamilyName or {} 

662 """A dictionary of localised style map 

663 familyname strings, keyed by language code. 

664 """ 

665 self.localisedStyleMapStyleName = localisedStyleMapStyleName or {} 

666 """A dictionary of localised style map 

667 stylename strings, keyed by language code. 

668 """ 

669 self.glyphs = glyphs or {} 

670 """dict for special master definitions for glyphs. If glyphs 

671 need special masters (to record the results of executed rules for 

672 example). 

673 

674 MutatorMath. 

675 

676 .. deprecated:: 5.0 

677 Use rules or sparse sources instead. 

678 """ 

679 self.kerning = kerning 

680 """ bool. Indicates if this instance needs its kerning 

681 calculated. 

682 

683 MutatorMath. 

684 

685 .. deprecated:: 5.0 

686 """ 

687 self.info = info 

688 """bool. Indicated if this instance needs the interpolating 

689 font.info calculated. 

690 

691 .. deprecated:: 5.0 

692 """ 

693 

694 self.lib = lib or {} 

695 """Custom data associated with this instance.""" 

696 

697 @property 

698 def location(self): 

699 """dict. Axis values for this instance. 

700 

701 MutatorMath + varLib. 

702 

703 .. deprecated:: 5.0 

704 Use the more explicit alias for this property :attr:`designLocation`. 

705 """ 

706 return self.designLocation 

707 

708 @location.setter 

709 def location(self, location: Optional[AnisotropicLocationDict]): 

710 self.designLocation = location or {} 

711 

712 def setStyleName(self, styleName, languageCode="en"): 

713 """These methods give easier access to the localised names.""" 

714 self.localisedStyleName[languageCode] = tostr(styleName) 

715 

716 def getStyleName(self, languageCode="en"): 

717 return self.localisedStyleName.get(languageCode) 

718 

719 def setFamilyName(self, familyName, languageCode="en"): 

720 self.localisedFamilyName[languageCode] = tostr(familyName) 

721 

722 def getFamilyName(self, languageCode="en"): 

723 return self.localisedFamilyName.get(languageCode) 

724 

725 def setStyleMapStyleName(self, styleMapStyleName, languageCode="en"): 

726 self.localisedStyleMapStyleName[languageCode] = tostr(styleMapStyleName) 

727 

728 def getStyleMapStyleName(self, languageCode="en"): 

729 return self.localisedStyleMapStyleName.get(languageCode) 

730 

731 def setStyleMapFamilyName(self, styleMapFamilyName, languageCode="en"): 

732 self.localisedStyleMapFamilyName[languageCode] = tostr(styleMapFamilyName) 

733 

734 def getStyleMapFamilyName(self, languageCode="en"): 

735 return self.localisedStyleMapFamilyName.get(languageCode) 

736 

737 def clearLocation(self, axisName: Optional[str] = None): 

738 """Clear all location-related fields. Ensures that 

739 :attr:``designLocation`` and :attr:``userLocation`` are dictionaries 

740 (possibly empty if clearing everything). 

741 

742 In order to update the location of this instance wholesale, a user 

743 should first clear all the fields, then change the field(s) for which 

744 they have data. 

745 

746 .. code:: python 

747 

748 instance.clearLocation() 

749 instance.designLocation = {'Weight': (34, 36.5), 'Width': 100} 

750 instance.userLocation = {'Opsz': 16} 

751 

752 In order to update a single axis location, the user should only clear 

753 that axis, then edit the values: 

754 

755 .. code:: python 

756 

757 instance.clearLocation('Weight') 

758 instance.designLocation['Weight'] = (34, 36.5) 

759 

760 Args: 

761 axisName: if provided, only clear the location for that axis. 

762 

763 .. versionadded:: 5.0 

764 """ 

765 self.locationLabel = None 

766 if axisName is None: 

767 self.designLocation = {} 

768 self.userLocation = {} 

769 else: 

770 if self.designLocation is None: 

771 self.designLocation = {} 

772 if axisName in self.designLocation: 

773 del self.designLocation[axisName] 

774 if self.userLocation is None: 

775 self.userLocation = {} 

776 if axisName in self.userLocation: 

777 del self.userLocation[axisName] 

778 

779 def getLocationLabelDescriptor( 

780 self, doc: "DesignSpaceDocument" 

781 ) -> Optional[LocationLabelDescriptor]: 

782 """Get the :class:`LocationLabelDescriptor` instance that matches 

783 this instances's :attr:`locationLabel`. 

784 

785 Raises if the named label can't be found. 

786 

787 .. versionadded:: 5.0 

788 """ 

789 if self.locationLabel is None: 

790 return None 

791 label = doc.getLocationLabel(self.locationLabel) 

792 if label is None: 

793 raise DesignSpaceDocumentError( 

794 "InstanceDescriptor.getLocationLabelDescriptor(): " 

795 f"unknown location label `{self.locationLabel}` in instance `{self.name}`." 

796 ) 

797 return label 

798 

799 def getFullDesignLocation( 

800 self, doc: "DesignSpaceDocument" 

801 ) -> AnisotropicLocationDict: 

802 """Get the complete design location of this instance, by combining data 

803 from the various location fields, default axis values and mappings, and 

804 top-level location labels. 

805 

806 The source of truth for this instance's location is determined for each 

807 axis independently by taking the first not-None field in this list: 

808 

809 - ``locationLabel``: the location along this axis is the same as the 

810 matching STAT format 4 label. No anisotropy. 

811 - ``designLocation[axisName]``: the explicit design location along this 

812 axis, possibly anisotropic. 

813 - ``userLocation[axisName]``: the explicit user location along this 

814 axis. No anisotropy. 

815 - ``axis.default``: default axis value. No anisotropy. 

816 

817 .. versionadded:: 5.0 

818 """ 

819 label = self.getLocationLabelDescriptor(doc) 

820 if label is not None: 

821 return doc.map_forward(label.userLocation) # type: ignore 

822 result: AnisotropicLocationDict = {} 

823 for axis in doc.axes: 

824 if axis.name in self.designLocation: 

825 result[axis.name] = self.designLocation[axis.name] 

826 elif axis.name in self.userLocation: 

827 result[axis.name] = axis.map_forward(self.userLocation[axis.name]) 

828 else: 

829 result[axis.name] = axis.map_forward(axis.default) 

830 return result 

831 

832 def getFullUserLocation(self, doc: "DesignSpaceDocument") -> SimpleLocationDict: 

833 """Get the complete user location for this instance. 

834 

835 .. seealso:: :meth:`getFullDesignLocation` 

836 

837 .. versionadded:: 5.0 

838 """ 

839 return doc.map_backward(self.getFullDesignLocation(doc)) 

840 

841 

842def tagForAxisName(name): 

843 # try to find or make a tag name for this axis name 

844 names = { 

845 "weight": ("wght", dict(en="Weight")), 

846 "width": ("wdth", dict(en="Width")), 

847 "optical": ("opsz", dict(en="Optical Size")), 

848 "slant": ("slnt", dict(en="Slant")), 

849 "italic": ("ital", dict(en="Italic")), 

850 } 

851 if name.lower() in names: 

852 return names[name.lower()] 

853 if len(name) < 4: 

854 tag = name + "*" * (4 - len(name)) 

855 else: 

856 tag = name[:4] 

857 return tag, dict(en=name) 

858 

859 

860class AbstractAxisDescriptor(SimpleDescriptor): 

861 flavor = "axis" 

862 

863 def __init__( 

864 self, 

865 *, 

866 tag=None, 

867 name=None, 

868 labelNames=None, 

869 hidden=False, 

870 map=None, 

871 axisOrdering=None, 

872 axisLabels=None, 

873 ): 

874 # opentype tag for this axis 

875 self.tag = tag 

876 """string. Four letter tag for this axis. Some might be 

877 registered at the `OpenType 

878 specification <https://www.microsoft.com/typography/otspec/fvar.htm#VAT>`__. 

879 Privately-defined axis tags must begin with an uppercase letter and 

880 use only uppercase letters or digits. 

881 """ 

882 # name of the axis used in locations 

883 self.name = name 

884 """string. Name of the axis as it is used in the location dicts. 

885 

886 MutatorMath + varLib. 

887 """ 

888 # names for UI purposes, if this is not a standard axis, 

889 self.labelNames = labelNames or {} 

890 """dict. When defining a non-registered axis, it will be 

891 necessary to define user-facing readable names for the axis. Keyed by 

892 xml:lang code. Values are required to be ``unicode`` strings, even if 

893 they only contain ASCII characters. 

894 """ 

895 self.hidden = hidden 

896 """bool. Whether this axis should be hidden in user interfaces. 

897 """ 

898 self.map = map or [] 

899 """list of input / output values that can describe a warp of user space 

900 to design space coordinates. If no map values are present, it is assumed 

901 user space is the same as design space, as in [(minimum, minimum), 

902 (maximum, maximum)]. 

903 

904 varLib. 

905 """ 

906 self.axisOrdering = axisOrdering 

907 """STAT table field ``axisOrdering``. 

908 

909 See: `OTSpec STAT Axis Record <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#axis-records>`_ 

910 

911 .. versionadded:: 5.0 

912 """ 

913 self.axisLabels: List[AxisLabelDescriptor] = axisLabels or [] 

914 """STAT table entries for Axis Value Tables format 1, 2, 3. 

915 

916 See: `OTSpec STAT Axis Value Tables <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#axis-value-tables>`_ 

917 

918 .. versionadded:: 5.0 

919 """ 

920 

921 

922class AxisDescriptor(AbstractAxisDescriptor): 

923 """Simple container for the axis data. 

924 

925 Add more localisations? 

926 

927 .. code:: python 

928 

929 a1 = AxisDescriptor() 

930 a1.minimum = 1 

931 a1.maximum = 1000 

932 a1.default = 400 

933 a1.name = "weight" 

934 a1.tag = "wght" 

935 a1.labelNames['fa-IR'] = "قطر" 

936 a1.labelNames['en'] = "Wéíght" 

937 a1.map = [(1.0, 10.0), (400.0, 66.0), (1000.0, 990.0)] 

938 a1.axisOrdering = 1 

939 a1.axisLabels = [ 

940 AxisLabelDescriptor(name="Regular", userValue=400, elidable=True) 

941 ] 

942 doc.addAxis(a1) 

943 """ 

944 

945 _attrs = [ 

946 "tag", 

947 "name", 

948 "maximum", 

949 "minimum", 

950 "default", 

951 "map", 

952 "axisOrdering", 

953 "axisLabels", 

954 ] 

955 

956 def __init__( 

957 self, 

958 *, 

959 tag=None, 

960 name=None, 

961 labelNames=None, 

962 minimum=None, 

963 default=None, 

964 maximum=None, 

965 hidden=False, 

966 map=None, 

967 axisOrdering=None, 

968 axisLabels=None, 

969 ): 

970 super().__init__( 

971 tag=tag, 

972 name=name, 

973 labelNames=labelNames, 

974 hidden=hidden, 

975 map=map, 

976 axisOrdering=axisOrdering, 

977 axisLabels=axisLabels, 

978 ) 

979 self.minimum = minimum 

980 """number. The minimum value for this axis in user space. 

981 

982 MutatorMath + varLib. 

983 """ 

984 self.maximum = maximum 

985 """number. The maximum value for this axis in user space. 

986 

987 MutatorMath + varLib. 

988 """ 

989 self.default = default 

990 """number. The default value for this axis, i.e. when a new location is 

991 created, this is the value this axis will get in user space. 

992 

993 MutatorMath + varLib. 

994 """ 

995 

996 def serialize(self): 

997 # output to a dict, used in testing 

998 return dict( 

999 tag=self.tag, 

1000 name=self.name, 

1001 labelNames=self.labelNames, 

1002 maximum=self.maximum, 

1003 minimum=self.minimum, 

1004 default=self.default, 

1005 hidden=self.hidden, 

1006 map=self.map, 

1007 axisOrdering=self.axisOrdering, 

1008 axisLabels=self.axisLabels, 

1009 ) 

1010 

1011 def map_forward(self, v): 

1012 """Maps value from axis mapping's input (user) to output (design).""" 

1013 from fontTools.varLib.models import piecewiseLinearMap 

1014 

1015 if not self.map: 

1016 return v 

1017 return piecewiseLinearMap(v, {k: v for k, v in self.map}) 

1018 

1019 def map_backward(self, v): 

1020 """Maps value from axis mapping's output (design) to input (user).""" 

1021 from fontTools.varLib.models import piecewiseLinearMap 

1022 

1023 if isinstance(v, tuple): 

1024 v = v[0] 

1025 if not self.map: 

1026 return v 

1027 return piecewiseLinearMap(v, {v: k for k, v in self.map}) 

1028 

1029 

1030class DiscreteAxisDescriptor(AbstractAxisDescriptor): 

1031 """Container for discrete axis data. 

1032 

1033 Use this for axes that do not interpolate. The main difference from a 

1034 continuous axis is that a continuous axis has a ``minimum`` and ``maximum``, 

1035 while a discrete axis has a list of ``values``. 

1036 

1037 Example: an Italic axis with 2 stops, Roman and Italic, that are not 

1038 compatible. The axis still allows to bind together the full font family, 

1039 which is useful for the STAT table, however it can't become a variation 

1040 axis in a VF. 

1041 

1042 .. code:: python 

1043 

1044 a2 = DiscreteAxisDescriptor() 

1045 a2.values = [0, 1] 

1046 a2.default = 0 

1047 a2.name = "Italic" 

1048 a2.tag = "ITAL" 

1049 a2.labelNames['fr'] = "Italique" 

1050 a2.map = [(0, 0), (1, -11)] 

1051 a2.axisOrdering = 2 

1052 a2.axisLabels = [ 

1053 AxisLabelDescriptor(name="Roman", userValue=0, elidable=True) 

1054 ] 

1055 doc.addAxis(a2) 

1056 

1057 .. versionadded:: 5.0 

1058 """ 

1059 

1060 flavor = "axis" 

1061 _attrs = ("tag", "name", "values", "default", "map", "axisOrdering", "axisLabels") 

1062 

1063 def __init__( 

1064 self, 

1065 *, 

1066 tag=None, 

1067 name=None, 

1068 labelNames=None, 

1069 values=None, 

1070 default=None, 

1071 hidden=False, 

1072 map=None, 

1073 axisOrdering=None, 

1074 axisLabels=None, 

1075 ): 

1076 super().__init__( 

1077 tag=tag, 

1078 name=name, 

1079 labelNames=labelNames, 

1080 hidden=hidden, 

1081 map=map, 

1082 axisOrdering=axisOrdering, 

1083 axisLabels=axisLabels, 

1084 ) 

1085 self.default: float = default 

1086 """The default value for this axis, i.e. when a new location is 

1087 created, this is the value this axis will get in user space. 

1088 

1089 However, this default value is less important than in continuous axes: 

1090 

1091 - it doesn't define the "neutral" version of outlines from which 

1092 deltas would apply, as this axis does not interpolate. 

1093 - it doesn't provide the reference glyph set for the designspace, as 

1094 fonts at each value can have different glyph sets. 

1095 """ 

1096 self.values: List[float] = values or [] 

1097 """List of possible values for this axis. Contrary to continuous axes, 

1098 only the values in this list can be taken by the axis, nothing in-between. 

1099 """ 

1100 

1101 def map_forward(self, value): 

1102 """Maps value from axis mapping's input to output. 

1103 

1104 Returns value unchanged if no mapping entry is found. 

1105 

1106 Note: for discrete axes, each value must have its mapping entry, if 

1107 you intend that value to be mapped. 

1108 """ 

1109 return next((v for k, v in self.map if k == value), value) 

1110 

1111 def map_backward(self, value): 

1112 """Maps value from axis mapping's output to input. 

1113 

1114 Returns value unchanged if no mapping entry is found. 

1115 

1116 Note: for discrete axes, each value must have its mapping entry, if 

1117 you intend that value to be mapped. 

1118 """ 

1119 if isinstance(value, tuple): 

1120 value = value[0] 

1121 return next((k for k, v in self.map if v == value), value) 

1122 

1123 

1124class AxisLabelDescriptor(SimpleDescriptor): 

1125 """Container for axis label data. 

1126 

1127 Analogue of OpenType's STAT data for a single axis (formats 1, 2 and 3). 

1128 All values are user values. 

1129 See: `OTSpec STAT Axis value table, format 1, 2, 3 <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#axis-value-table-format-1>`_ 

1130 

1131 The STAT format of the Axis value depends on which field are filled-in, 

1132 see :meth:`getFormat` 

1133 

1134 .. versionadded:: 5.0 

1135 """ 

1136 

1137 flavor = "label" 

1138 _attrs = ( 

1139 "userMinimum", 

1140 "userValue", 

1141 "userMaximum", 

1142 "name", 

1143 "elidable", 

1144 "olderSibling", 

1145 "linkedUserValue", 

1146 "labelNames", 

1147 ) 

1148 

1149 def __init__( 

1150 self, 

1151 *, 

1152 name, 

1153 userValue, 

1154 userMinimum=None, 

1155 userMaximum=None, 

1156 elidable=False, 

1157 olderSibling=False, 

1158 linkedUserValue=None, 

1159 labelNames=None, 

1160 ): 

1161 self.userMinimum: Optional[float] = userMinimum 

1162 """STAT field ``rangeMinValue`` (format 2).""" 

1163 self.userValue: float = userValue 

1164 """STAT field ``value`` (format 1, 3) or ``nominalValue`` (format 2).""" 

1165 self.userMaximum: Optional[float] = userMaximum 

1166 """STAT field ``rangeMaxValue`` (format 2).""" 

1167 self.name: str = name 

1168 """Label for this axis location, STAT field ``valueNameID``.""" 

1169 self.elidable: bool = elidable 

1170 """STAT flag ``ELIDABLE_AXIS_VALUE_NAME``. 

1171 

1172 See: `OTSpec STAT Flags <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#flags>`_ 

1173 """ 

1174 self.olderSibling: bool = olderSibling 

1175 """STAT flag ``OLDER_SIBLING_FONT_ATTRIBUTE``. 

1176 

1177 See: `OTSpec STAT Flags <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#flags>`_ 

1178 """ 

1179 self.linkedUserValue: Optional[float] = linkedUserValue 

1180 """STAT field ``linkedValue`` (format 3).""" 

1181 self.labelNames: MutableMapping[str, str] = labelNames or {} 

1182 """User-facing translations of this location's label. Keyed by 

1183 ``xml:lang`` code. 

1184 """ 

1185 

1186 def getFormat(self) -> int: 

1187 """Determine which format of STAT Axis value to use to encode this label. 

1188 

1189 =========== ========= =========== =========== =============== 

1190 STAT Format userValue userMinimum userMaximum linkedUserValue 

1191 =========== ========= =========== =========== =============== 

1192 1 ✅ ❌ ❌ ❌ 

1193 2 ✅ ✅ ✅ ❌ 

1194 3 ✅ ❌ ❌ ✅ 

1195 =========== ========= =========== =========== =============== 

1196 """ 

1197 if self.linkedUserValue is not None: 

1198 return 3 

1199 if self.userMinimum is not None or self.userMaximum is not None: 

1200 return 2 

1201 return 1 

1202 

1203 @property 

1204 def defaultName(self) -> str: 

1205 """Return the English name from :attr:`labelNames` or the :attr:`name`.""" 

1206 return self.labelNames.get("en") or self.name 

1207 

1208 

1209class LocationLabelDescriptor(SimpleDescriptor): 

1210 """Container for location label data. 

1211 

1212 Analogue of OpenType's STAT data for a free-floating location (format 4). 

1213 All values are user values. 

1214 

1215 See: `OTSpec STAT Axis value table, format 4 <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#axis-value-table-format-4>`_ 

1216 

1217 .. versionadded:: 5.0 

1218 """ 

1219 

1220 flavor = "label" 

1221 _attrs = ("name", "elidable", "olderSibling", "userLocation", "labelNames") 

1222 

1223 def __init__( 

1224 self, 

1225 *, 

1226 name, 

1227 userLocation, 

1228 elidable=False, 

1229 olderSibling=False, 

1230 labelNames=None, 

1231 ): 

1232 self.name: str = name 

1233 """Label for this named location, STAT field ``valueNameID``.""" 

1234 self.userLocation: SimpleLocationDict = userLocation or {} 

1235 """Location in user coordinates along each axis. 

1236 

1237 If an axis is not mentioned, it is assumed to be at its default location. 

1238 

1239 .. seealso:: This may be only part of the full location. See: 

1240 :meth:`getFullUserLocation` 

1241 """ 

1242 self.elidable: bool = elidable 

1243 """STAT flag ``ELIDABLE_AXIS_VALUE_NAME``. 

1244 

1245 See: `OTSpec STAT Flags <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#flags>`_ 

1246 """ 

1247 self.olderSibling: bool = olderSibling 

1248 """STAT flag ``OLDER_SIBLING_FONT_ATTRIBUTE``. 

1249 

1250 See: `OTSpec STAT Flags <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#flags>`_ 

1251 """ 

1252 self.labelNames: Dict[str, str] = labelNames or {} 

1253 """User-facing translations of this location's label. Keyed by 

1254 xml:lang code. 

1255 """ 

1256 

1257 @property 

1258 def defaultName(self) -> str: 

1259 """Return the English name from :attr:`labelNames` or the :attr:`name`.""" 

1260 return self.labelNames.get("en") or self.name 

1261 

1262 def getFullUserLocation(self, doc: "DesignSpaceDocument") -> SimpleLocationDict: 

1263 """Get the complete user location of this label, by combining data 

1264 from the explicit user location and default axis values. 

1265 

1266 .. versionadded:: 5.0 

1267 """ 

1268 return { 

1269 axis.name: self.userLocation.get(axis.name, axis.default) 

1270 for axis in doc.axes 

1271 } 

1272 

1273 

1274class VariableFontDescriptor(SimpleDescriptor): 

1275 """Container for variable fonts, sub-spaces of the Designspace. 

1276 

1277 Use-cases: 

1278 

1279 - From a single DesignSpace with discrete axes, define 1 variable font 

1280 per value on the discrete axes. Before version 5, you would have needed 

1281 1 DesignSpace per such variable font, and a lot of data duplication. 

1282 - From a big variable font with many axes, define subsets of that variable 

1283 font that only include some axes and freeze other axes at a given location. 

1284 

1285 .. versionadded:: 5.0 

1286 """ 

1287 

1288 flavor = "variable-font" 

1289 _attrs = ("filename", "axisSubsets", "lib") 

1290 

1291 filename = posixpath_property("_filename") 

1292 

1293 def __init__(self, *, name, filename=None, axisSubsets=None, lib=None): 

1294 self.name: str = name 

1295 """string, required. Name of this variable to identify it during the 

1296 build process and from other parts of the document, and also as a 

1297 filename in case the filename property is empty. 

1298 

1299 VarLib. 

1300 """ 

1301 self.filename: str = filename 

1302 """string, optional. Relative path to the variable font file, **as it is 

1303 in the document**. The file may or may not exist. 

1304 

1305 If not specified, the :attr:`name` will be used as a basename for the file. 

1306 """ 

1307 self.axisSubsets: List[ 

1308 Union[RangeAxisSubsetDescriptor, ValueAxisSubsetDescriptor] 

1309 ] = (axisSubsets or []) 

1310 """Axis subsets to include in this variable font. 

1311 

1312 If an axis is not mentioned, assume that we only want the default 

1313 location of that axis (same as a :class:`ValueAxisSubsetDescriptor`). 

1314 """ 

1315 self.lib: MutableMapping[str, Any] = lib or {} 

1316 """Custom data associated with this variable font.""" 

1317 

1318 

1319class RangeAxisSubsetDescriptor(SimpleDescriptor): 

1320 """Subset of a continuous axis to include in a variable font. 

1321 

1322 .. versionadded:: 5.0 

1323 """ 

1324 

1325 flavor = "axis-subset" 

1326 _attrs = ("name", "userMinimum", "userDefault", "userMaximum") 

1327 

1328 def __init__( 

1329 self, *, name, userMinimum=-math.inf, userDefault=None, userMaximum=math.inf 

1330 ): 

1331 self.name: str = name 

1332 """Name of the :class:`AxisDescriptor` to subset.""" 

1333 self.userMinimum: float = userMinimum 

1334 """New minimum value of the axis in the target variable font. 

1335 If not specified, assume the same minimum value as the full axis. 

1336 (default = ``-math.inf``) 

1337 """ 

1338 self.userDefault: Optional[float] = userDefault 

1339 """New default value of the axis in the target variable font. 

1340 If not specified, assume the same default value as the full axis. 

1341 (default = ``None``) 

1342 """ 

1343 self.userMaximum: float = userMaximum 

1344 """New maximum value of the axis in the target variable font. 

1345 If not specified, assume the same maximum value as the full axis. 

1346 (default = ``math.inf``) 

1347 """ 

1348 

1349 

1350class ValueAxisSubsetDescriptor(SimpleDescriptor): 

1351 """Single value of a discrete or continuous axis to use in a variable font. 

1352 

1353 .. versionadded:: 5.0 

1354 """ 

1355 

1356 flavor = "axis-subset" 

1357 _attrs = ("name", "userValue") 

1358 

1359 def __init__(self, *, name, userValue): 

1360 self.name: str = name 

1361 """Name of the :class:`AxisDescriptor` or :class:`DiscreteAxisDescriptor` 

1362 to "snapshot" or "freeze". 

1363 """ 

1364 self.userValue: float = userValue 

1365 """Value in user coordinates at which to freeze the given axis.""" 

1366 

1367 

1368class BaseDocWriter(object): 

1369 _whiteSpace = " " 

1370 axisDescriptorClass = AxisDescriptor 

1371 discreteAxisDescriptorClass = DiscreteAxisDescriptor 

1372 axisLabelDescriptorClass = AxisLabelDescriptor 

1373 axisMappingDescriptorClass = AxisMappingDescriptor 

1374 locationLabelDescriptorClass = LocationLabelDescriptor 

1375 ruleDescriptorClass = RuleDescriptor 

1376 sourceDescriptorClass = SourceDescriptor 

1377 variableFontDescriptorClass = VariableFontDescriptor 

1378 valueAxisSubsetDescriptorClass = ValueAxisSubsetDescriptor 

1379 rangeAxisSubsetDescriptorClass = RangeAxisSubsetDescriptor 

1380 instanceDescriptorClass = InstanceDescriptor 

1381 

1382 @classmethod 

1383 def getAxisDecriptor(cls): 

1384 return cls.axisDescriptorClass() 

1385 

1386 @classmethod 

1387 def getAxisMappingDescriptor(cls): 

1388 return cls.axisMappingDescriptorClass() 

1389 

1390 @classmethod 

1391 def getSourceDescriptor(cls): 

1392 return cls.sourceDescriptorClass() 

1393 

1394 @classmethod 

1395 def getInstanceDescriptor(cls): 

1396 return cls.instanceDescriptorClass() 

1397 

1398 @classmethod 

1399 def getRuleDescriptor(cls): 

1400 return cls.ruleDescriptorClass() 

1401 

1402 def __init__(self, documentPath, documentObject: DesignSpaceDocument): 

1403 self.path = documentPath 

1404 self.documentObject = documentObject 

1405 self.effectiveFormatTuple = self._getEffectiveFormatTuple() 

1406 self.root = ET.Element("designspace") 

1407 

1408 def write(self, pretty=True, encoding="UTF-8", xml_declaration=True): 

1409 self.root.attrib["format"] = ".".join(str(i) for i in self.effectiveFormatTuple) 

1410 

1411 if ( 

1412 self.documentObject.axes 

1413 or self.documentObject.axisMappings 

1414 or self.documentObject.elidedFallbackName is not None 

1415 ): 

1416 axesElement = ET.Element("axes") 

1417 if self.documentObject.elidedFallbackName is not None: 

1418 axesElement.attrib[ 

1419 "elidedfallbackname" 

1420 ] = self.documentObject.elidedFallbackName 

1421 self.root.append(axesElement) 

1422 for axisObject in self.documentObject.axes: 

1423 self._addAxis(axisObject) 

1424 

1425 if self.documentObject.axisMappings: 

1426 mappingsElement = ET.Element("mappings") 

1427 self.root.findall(".axes")[0].append(mappingsElement) 

1428 for mappingObject in self.documentObject.axisMappings: 

1429 self._addAxisMapping(mappingsElement, mappingObject) 

1430 

1431 if self.documentObject.locationLabels: 

1432 labelsElement = ET.Element("labels") 

1433 for labelObject in self.documentObject.locationLabels: 

1434 self._addLocationLabel(labelsElement, labelObject) 

1435 self.root.append(labelsElement) 

1436 

1437 if self.documentObject.rules: 

1438 if getattr(self.documentObject, "rulesProcessingLast", False): 

1439 attributes = {"processing": "last"} 

1440 else: 

1441 attributes = {} 

1442 self.root.append(ET.Element("rules", attributes)) 

1443 for ruleObject in self.documentObject.rules: 

1444 self._addRule(ruleObject) 

1445 

1446 if self.documentObject.sources: 

1447 self.root.append(ET.Element("sources")) 

1448 for sourceObject in self.documentObject.sources: 

1449 self._addSource(sourceObject) 

1450 

1451 if self.documentObject.variableFonts: 

1452 variableFontsElement = ET.Element("variable-fonts") 

1453 for variableFont in self.documentObject.variableFonts: 

1454 self._addVariableFont(variableFontsElement, variableFont) 

1455 self.root.append(variableFontsElement) 

1456 

1457 if self.documentObject.instances: 

1458 self.root.append(ET.Element("instances")) 

1459 for instanceObject in self.documentObject.instances: 

1460 self._addInstance(instanceObject) 

1461 

1462 if self.documentObject.lib: 

1463 self._addLib(self.root, self.documentObject.lib, 2) 

1464 

1465 tree = ET.ElementTree(self.root) 

1466 tree.write( 

1467 self.path, 

1468 encoding=encoding, 

1469 method="xml", 

1470 xml_declaration=xml_declaration, 

1471 pretty_print=pretty, 

1472 ) 

1473 

1474 def _getEffectiveFormatTuple(self): 

1475 """Try to use the version specified in the document, or a sufficiently 

1476 recent version to be able to encode what the document contains. 

1477 """ 

1478 minVersion = self.documentObject.formatTuple 

1479 if ( 

1480 any( 

1481 hasattr(axis, "values") 

1482 or axis.axisOrdering is not None 

1483 or axis.axisLabels 

1484 for axis in self.documentObject.axes 

1485 ) 

1486 or self.documentObject.locationLabels 

1487 or any(source.localisedFamilyName for source in self.documentObject.sources) 

1488 or self.documentObject.variableFonts 

1489 or any( 

1490 instance.locationLabel or instance.userLocation 

1491 for instance in self.documentObject.instances 

1492 ) 

1493 ): 

1494 if minVersion < (5, 0): 

1495 minVersion = (5, 0) 

1496 if self.documentObject.axisMappings: 

1497 if minVersion < (5, 1): 

1498 minVersion = (5, 1) 

1499 return minVersion 

1500 

1501 def _makeLocationElement(self, locationObject, name=None): 

1502 """Convert Location dict to a locationElement.""" 

1503 locElement = ET.Element("location") 

1504 if name is not None: 

1505 locElement.attrib["name"] = name 

1506 validatedLocation = self.documentObject.newDefaultLocation() 

1507 for axisName, axisValue in locationObject.items(): 

1508 if axisName in validatedLocation: 

1509 # only accept values we know 

1510 validatedLocation[axisName] = axisValue 

1511 for dimensionName, dimensionValue in validatedLocation.items(): 

1512 dimElement = ET.Element("dimension") 

1513 dimElement.attrib["name"] = dimensionName 

1514 if type(dimensionValue) == tuple: 

1515 dimElement.attrib["xvalue"] = self.intOrFloat(dimensionValue[0]) 

1516 dimElement.attrib["yvalue"] = self.intOrFloat(dimensionValue[1]) 

1517 else: 

1518 dimElement.attrib["xvalue"] = self.intOrFloat(dimensionValue) 

1519 locElement.append(dimElement) 

1520 return locElement, validatedLocation 

1521 

1522 def intOrFloat(self, num): 

1523 if int(num) == num: 

1524 return "%d" % num 

1525 return ("%f" % num).rstrip("0").rstrip(".") 

1526 

1527 def _addRule(self, ruleObject): 

1528 # if none of the conditions have minimum or maximum values, do not add the rule. 

1529 ruleElement = ET.Element("rule") 

1530 if ruleObject.name is not None: 

1531 ruleElement.attrib["name"] = ruleObject.name 

1532 for conditions in ruleObject.conditionSets: 

1533 conditionsetElement = ET.Element("conditionset") 

1534 for cond in conditions: 

1535 if cond.get("minimum") is None and cond.get("maximum") is None: 

1536 # neither is defined, don't add this condition 

1537 continue 

1538 conditionElement = ET.Element("condition") 

1539 conditionElement.attrib["name"] = cond.get("name") 

1540 if cond.get("minimum") is not None: 

1541 conditionElement.attrib["minimum"] = self.intOrFloat( 

1542 cond.get("minimum") 

1543 ) 

1544 if cond.get("maximum") is not None: 

1545 conditionElement.attrib["maximum"] = self.intOrFloat( 

1546 cond.get("maximum") 

1547 ) 

1548 conditionsetElement.append(conditionElement) 

1549 if len(conditionsetElement): 

1550 ruleElement.append(conditionsetElement) 

1551 for sub in ruleObject.subs: 

1552 subElement = ET.Element("sub") 

1553 subElement.attrib["name"] = sub[0] 

1554 subElement.attrib["with"] = sub[1] 

1555 ruleElement.append(subElement) 

1556 if len(ruleElement): 

1557 self.root.findall(".rules")[0].append(ruleElement) 

1558 

1559 def _addAxis(self, axisObject): 

1560 axisElement = ET.Element("axis") 

1561 axisElement.attrib["tag"] = axisObject.tag 

1562 axisElement.attrib["name"] = axisObject.name 

1563 self._addLabelNames(axisElement, axisObject.labelNames) 

1564 if axisObject.map: 

1565 for inputValue, outputValue in axisObject.map: 

1566 mapElement = ET.Element("map") 

1567 mapElement.attrib["input"] = self.intOrFloat(inputValue) 

1568 mapElement.attrib["output"] = self.intOrFloat(outputValue) 

1569 axisElement.append(mapElement) 

1570 if axisObject.axisOrdering or axisObject.axisLabels: 

1571 labelsElement = ET.Element("labels") 

1572 if axisObject.axisOrdering is not None: 

1573 labelsElement.attrib["ordering"] = str(axisObject.axisOrdering) 

1574 for label in axisObject.axisLabels: 

1575 self._addAxisLabel(labelsElement, label) 

1576 axisElement.append(labelsElement) 

1577 if hasattr(axisObject, "minimum"): 

1578 axisElement.attrib["minimum"] = self.intOrFloat(axisObject.minimum) 

1579 axisElement.attrib["maximum"] = self.intOrFloat(axisObject.maximum) 

1580 elif hasattr(axisObject, "values"): 

1581 axisElement.attrib["values"] = " ".join( 

1582 self.intOrFloat(v) for v in axisObject.values 

1583 ) 

1584 axisElement.attrib["default"] = self.intOrFloat(axisObject.default) 

1585 if axisObject.hidden: 

1586 axisElement.attrib["hidden"] = "1" 

1587 self.root.findall(".axes")[0].append(axisElement) 

1588 

1589 def _addAxisMapping(self, mappingsElement, mappingObject): 

1590 mappingElement = ET.Element("mapping") 

1591 for what in ("inputLocation", "outputLocation"): 

1592 whatObject = getattr(mappingObject, what, None) 

1593 if whatObject is None: 

1594 continue 

1595 whatElement = ET.Element(what[:-8]) 

1596 mappingElement.append(whatElement) 

1597 

1598 for name, value in whatObject.items(): 

1599 dimensionElement = ET.Element("dimension") 

1600 dimensionElement.attrib["name"] = name 

1601 dimensionElement.attrib["xvalue"] = self.intOrFloat(value) 

1602 whatElement.append(dimensionElement) 

1603 

1604 mappingsElement.append(mappingElement) 

1605 

1606 def _addAxisLabel( 

1607 self, axisElement: ET.Element, label: AxisLabelDescriptor 

1608 ) -> None: 

1609 labelElement = ET.Element("label") 

1610 labelElement.attrib["uservalue"] = self.intOrFloat(label.userValue) 

1611 if label.userMinimum is not None: 

1612 labelElement.attrib["userminimum"] = self.intOrFloat(label.userMinimum) 

1613 if label.userMaximum is not None: 

1614 labelElement.attrib["usermaximum"] = self.intOrFloat(label.userMaximum) 

1615 labelElement.attrib["name"] = label.name 

1616 if label.elidable: 

1617 labelElement.attrib["elidable"] = "true" 

1618 if label.olderSibling: 

1619 labelElement.attrib["oldersibling"] = "true" 

1620 if label.linkedUserValue is not None: 

1621 labelElement.attrib["linkeduservalue"] = self.intOrFloat( 

1622 label.linkedUserValue 

1623 ) 

1624 self._addLabelNames(labelElement, label.labelNames) 

1625 axisElement.append(labelElement) 

1626 

1627 def _addLabelNames(self, parentElement, labelNames): 

1628 for languageCode, labelName in sorted(labelNames.items()): 

1629 languageElement = ET.Element("labelname") 

1630 languageElement.attrib[XML_LANG] = languageCode 

1631 languageElement.text = labelName 

1632 parentElement.append(languageElement) 

1633 

1634 def _addLocationLabel( 

1635 self, parentElement: ET.Element, label: LocationLabelDescriptor 

1636 ) -> None: 

1637 labelElement = ET.Element("label") 

1638 labelElement.attrib["name"] = label.name 

1639 if label.elidable: 

1640 labelElement.attrib["elidable"] = "true" 

1641 if label.olderSibling: 

1642 labelElement.attrib["oldersibling"] = "true" 

1643 self._addLabelNames(labelElement, label.labelNames) 

1644 self._addLocationElement(labelElement, userLocation=label.userLocation) 

1645 parentElement.append(labelElement) 

1646 

1647 def _addLocationElement( 

1648 self, 

1649 parentElement, 

1650 *, 

1651 designLocation: AnisotropicLocationDict = None, 

1652 userLocation: SimpleLocationDict = None, 

1653 ): 

1654 locElement = ET.Element("location") 

1655 for axis in self.documentObject.axes: 

1656 if designLocation is not None and axis.name in designLocation: 

1657 dimElement = ET.Element("dimension") 

1658 dimElement.attrib["name"] = axis.name 

1659 value = designLocation[axis.name] 

1660 if isinstance(value, tuple): 

1661 dimElement.attrib["xvalue"] = self.intOrFloat(value[0]) 

1662 dimElement.attrib["yvalue"] = self.intOrFloat(value[1]) 

1663 else: 

1664 dimElement.attrib["xvalue"] = self.intOrFloat(value) 

1665 locElement.append(dimElement) 

1666 elif userLocation is not None and axis.name in userLocation: 

1667 dimElement = ET.Element("dimension") 

1668 dimElement.attrib["name"] = axis.name 

1669 value = userLocation[axis.name] 

1670 dimElement.attrib["uservalue"] = self.intOrFloat(value) 

1671 locElement.append(dimElement) 

1672 if len(locElement) > 0: 

1673 parentElement.append(locElement) 

1674 

1675 def _addInstance(self, instanceObject): 

1676 instanceElement = ET.Element("instance") 

1677 if instanceObject.name is not None: 

1678 instanceElement.attrib["name"] = instanceObject.name 

1679 if instanceObject.locationLabel is not None: 

1680 instanceElement.attrib["location"] = instanceObject.locationLabel 

1681 if instanceObject.familyName is not None: 

1682 instanceElement.attrib["familyname"] = instanceObject.familyName 

1683 if instanceObject.styleName is not None: 

1684 instanceElement.attrib["stylename"] = instanceObject.styleName 

1685 # add localisations 

1686 if instanceObject.localisedStyleName: 

1687 languageCodes = list(instanceObject.localisedStyleName.keys()) 

1688 languageCodes.sort() 

1689 for code in languageCodes: 

1690 if code == "en": 

1691 continue # already stored in the element attribute 

1692 localisedStyleNameElement = ET.Element("stylename") 

1693 localisedStyleNameElement.attrib[XML_LANG] = code 

1694 localisedStyleNameElement.text = instanceObject.getStyleName(code) 

1695 instanceElement.append(localisedStyleNameElement) 

1696 if instanceObject.localisedFamilyName: 

1697 languageCodes = list(instanceObject.localisedFamilyName.keys()) 

1698 languageCodes.sort() 

1699 for code in languageCodes: 

1700 if code == "en": 

1701 continue # already stored in the element attribute 

1702 localisedFamilyNameElement = ET.Element("familyname") 

1703 localisedFamilyNameElement.attrib[XML_LANG] = code 

1704 localisedFamilyNameElement.text = instanceObject.getFamilyName(code) 

1705 instanceElement.append(localisedFamilyNameElement) 

1706 if instanceObject.localisedStyleMapStyleName: 

1707 languageCodes = list(instanceObject.localisedStyleMapStyleName.keys()) 

1708 languageCodes.sort() 

1709 for code in languageCodes: 

1710 if code == "en": 

1711 continue 

1712 localisedStyleMapStyleNameElement = ET.Element("stylemapstylename") 

1713 localisedStyleMapStyleNameElement.attrib[XML_LANG] = code 

1714 localisedStyleMapStyleNameElement.text = ( 

1715 instanceObject.getStyleMapStyleName(code) 

1716 ) 

1717 instanceElement.append(localisedStyleMapStyleNameElement) 

1718 if instanceObject.localisedStyleMapFamilyName: 

1719 languageCodes = list(instanceObject.localisedStyleMapFamilyName.keys()) 

1720 languageCodes.sort() 

1721 for code in languageCodes: 

1722 if code == "en": 

1723 continue 

1724 localisedStyleMapFamilyNameElement = ET.Element("stylemapfamilyname") 

1725 localisedStyleMapFamilyNameElement.attrib[XML_LANG] = code 

1726 localisedStyleMapFamilyNameElement.text = ( 

1727 instanceObject.getStyleMapFamilyName(code) 

1728 ) 

1729 instanceElement.append(localisedStyleMapFamilyNameElement) 

1730 

1731 if self.effectiveFormatTuple >= (5, 0): 

1732 if instanceObject.locationLabel is None: 

1733 self._addLocationElement( 

1734 instanceElement, 

1735 designLocation=instanceObject.designLocation, 

1736 userLocation=instanceObject.userLocation, 

1737 ) 

1738 else: 

1739 # Pre-version 5.0 code was validating and filling in the location 

1740 # dict while writing it out, as preserved below. 

1741 if instanceObject.location is not None: 

1742 locationElement, instanceObject.location = self._makeLocationElement( 

1743 instanceObject.location 

1744 ) 

1745 instanceElement.append(locationElement) 

1746 if instanceObject.filename is not None: 

1747 instanceElement.attrib["filename"] = instanceObject.filename 

1748 if instanceObject.postScriptFontName is not None: 

1749 instanceElement.attrib[ 

1750 "postscriptfontname" 

1751 ] = instanceObject.postScriptFontName 

1752 if instanceObject.styleMapFamilyName is not None: 

1753 instanceElement.attrib[ 

1754 "stylemapfamilyname" 

1755 ] = instanceObject.styleMapFamilyName 

1756 if instanceObject.styleMapStyleName is not None: 

1757 instanceElement.attrib[ 

1758 "stylemapstylename" 

1759 ] = instanceObject.styleMapStyleName 

1760 if self.effectiveFormatTuple < (5, 0): 

1761 # Deprecated members as of version 5.0 

1762 if instanceObject.glyphs: 

1763 if instanceElement.findall(".glyphs") == []: 

1764 glyphsElement = ET.Element("glyphs") 

1765 instanceElement.append(glyphsElement) 

1766 glyphsElement = instanceElement.findall(".glyphs")[0] 

1767 for glyphName, data in sorted(instanceObject.glyphs.items()): 

1768 glyphElement = self._writeGlyphElement( 

1769 instanceElement, instanceObject, glyphName, data 

1770 ) 

1771 glyphsElement.append(glyphElement) 

1772 if instanceObject.kerning: 

1773 kerningElement = ET.Element("kerning") 

1774 instanceElement.append(kerningElement) 

1775 if instanceObject.info: 

1776 infoElement = ET.Element("info") 

1777 instanceElement.append(infoElement) 

1778 self._addLib(instanceElement, instanceObject.lib, 4) 

1779 self.root.findall(".instances")[0].append(instanceElement) 

1780 

1781 def _addSource(self, sourceObject): 

1782 sourceElement = ET.Element("source") 

1783 if sourceObject.filename is not None: 

1784 sourceElement.attrib["filename"] = sourceObject.filename 

1785 if sourceObject.name is not None: 

1786 if sourceObject.name.find("temp_master") != 0: 

1787 # do not save temporary source names 

1788 sourceElement.attrib["name"] = sourceObject.name 

1789 if sourceObject.familyName is not None: 

1790 sourceElement.attrib["familyname"] = sourceObject.familyName 

1791 if sourceObject.styleName is not None: 

1792 sourceElement.attrib["stylename"] = sourceObject.styleName 

1793 if sourceObject.layerName is not None: 

1794 sourceElement.attrib["layer"] = sourceObject.layerName 

1795 if sourceObject.localisedFamilyName: 

1796 languageCodes = list(sourceObject.localisedFamilyName.keys()) 

1797 languageCodes.sort() 

1798 for code in languageCodes: 

1799 if code == "en": 

1800 continue # already stored in the element attribute 

1801 localisedFamilyNameElement = ET.Element("familyname") 

1802 localisedFamilyNameElement.attrib[XML_LANG] = code 

1803 localisedFamilyNameElement.text = sourceObject.getFamilyName(code) 

1804 sourceElement.append(localisedFamilyNameElement) 

1805 if sourceObject.copyLib: 

1806 libElement = ET.Element("lib") 

1807 libElement.attrib["copy"] = "1" 

1808 sourceElement.append(libElement) 

1809 if sourceObject.copyGroups: 

1810 groupsElement = ET.Element("groups") 

1811 groupsElement.attrib["copy"] = "1" 

1812 sourceElement.append(groupsElement) 

1813 if sourceObject.copyFeatures: 

1814 featuresElement = ET.Element("features") 

1815 featuresElement.attrib["copy"] = "1" 

1816 sourceElement.append(featuresElement) 

1817 if sourceObject.copyInfo or sourceObject.muteInfo: 

1818 infoElement = ET.Element("info") 

1819 if sourceObject.copyInfo: 

1820 infoElement.attrib["copy"] = "1" 

1821 if sourceObject.muteInfo: 

1822 infoElement.attrib["mute"] = "1" 

1823 sourceElement.append(infoElement) 

1824 if sourceObject.muteKerning: 

1825 kerningElement = ET.Element("kerning") 

1826 kerningElement.attrib["mute"] = "1" 

1827 sourceElement.append(kerningElement) 

1828 if sourceObject.mutedGlyphNames: 

1829 for name in sourceObject.mutedGlyphNames: 

1830 glyphElement = ET.Element("glyph") 

1831 glyphElement.attrib["name"] = name 

1832 glyphElement.attrib["mute"] = "1" 

1833 sourceElement.append(glyphElement) 

1834 if self.effectiveFormatTuple >= (5, 0): 

1835 self._addLocationElement( 

1836 sourceElement, designLocation=sourceObject.location 

1837 ) 

1838 else: 

1839 # Pre-version 5.0 code was validating and filling in the location 

1840 # dict while writing it out, as preserved below. 

1841 locationElement, sourceObject.location = self._makeLocationElement( 

1842 sourceObject.location 

1843 ) 

1844 sourceElement.append(locationElement) 

1845 self.root.findall(".sources")[0].append(sourceElement) 

1846 

1847 def _addVariableFont( 

1848 self, parentElement: ET.Element, vf: VariableFontDescriptor 

1849 ) -> None: 

1850 vfElement = ET.Element("variable-font") 

1851 vfElement.attrib["name"] = vf.name 

1852 if vf.filename is not None: 

1853 vfElement.attrib["filename"] = vf.filename 

1854 if vf.axisSubsets: 

1855 subsetsElement = ET.Element("axis-subsets") 

1856 for subset in vf.axisSubsets: 

1857 subsetElement = ET.Element("axis-subset") 

1858 subsetElement.attrib["name"] = subset.name 

1859 # Mypy doesn't support narrowing union types via hasattr() 

1860 # https://mypy.readthedocs.io/en/stable/type_narrowing.html 

1861 # TODO(Python 3.10): use TypeGuard 

1862 if hasattr(subset, "userMinimum"): 

1863 subset = cast(RangeAxisSubsetDescriptor, subset) 

1864 if subset.userMinimum != -math.inf: 

1865 subsetElement.attrib["userminimum"] = self.intOrFloat( 

1866 subset.userMinimum 

1867 ) 

1868 if subset.userMaximum != math.inf: 

1869 subsetElement.attrib["usermaximum"] = self.intOrFloat( 

1870 subset.userMaximum 

1871 ) 

1872 if subset.userDefault is not None: 

1873 subsetElement.attrib["userdefault"] = self.intOrFloat( 

1874 subset.userDefault 

1875 ) 

1876 elif hasattr(subset, "userValue"): 

1877 subset = cast(ValueAxisSubsetDescriptor, subset) 

1878 subsetElement.attrib["uservalue"] = self.intOrFloat( 

1879 subset.userValue 

1880 ) 

1881 subsetsElement.append(subsetElement) 

1882 vfElement.append(subsetsElement) 

1883 self._addLib(vfElement, vf.lib, 4) 

1884 parentElement.append(vfElement) 

1885 

1886 def _addLib(self, parentElement: ET.Element, data: Any, indent_level: int) -> None: 

1887 if not data: 

1888 return 

1889 libElement = ET.Element("lib") 

1890 libElement.append(plistlib.totree(data, indent_level=indent_level)) 

1891 parentElement.append(libElement) 

1892 

1893 def _writeGlyphElement(self, instanceElement, instanceObject, glyphName, data): 

1894 glyphElement = ET.Element("glyph") 

1895 if data.get("mute"): 

1896 glyphElement.attrib["mute"] = "1" 

1897 if data.get("unicodes") is not None: 

1898 glyphElement.attrib["unicode"] = " ".join( 

1899 [hex(u) for u in data.get("unicodes")] 

1900 ) 

1901 if data.get("instanceLocation") is not None: 

1902 locationElement, data["instanceLocation"] = self._makeLocationElement( 

1903 data.get("instanceLocation") 

1904 ) 

1905 glyphElement.append(locationElement) 

1906 if glyphName is not None: 

1907 glyphElement.attrib["name"] = glyphName 

1908 if data.get("note") is not None: 

1909 noteElement = ET.Element("note") 

1910 noteElement.text = data.get("note") 

1911 glyphElement.append(noteElement) 

1912 if data.get("masters") is not None: 

1913 mastersElement = ET.Element("masters") 

1914 for m in data.get("masters"): 

1915 masterElement = ET.Element("master") 

1916 if m.get("glyphName") is not None: 

1917 masterElement.attrib["glyphname"] = m.get("glyphName") 

1918 if m.get("font") is not None: 

1919 masterElement.attrib["source"] = m.get("font") 

1920 if m.get("location") is not None: 

1921 locationElement, m["location"] = self._makeLocationElement( 

1922 m.get("location") 

1923 ) 

1924 masterElement.append(locationElement) 

1925 mastersElement.append(masterElement) 

1926 glyphElement.append(mastersElement) 

1927 return glyphElement 

1928 

1929 

1930class BaseDocReader(LogMixin): 

1931 axisDescriptorClass = AxisDescriptor 

1932 discreteAxisDescriptorClass = DiscreteAxisDescriptor 

1933 axisLabelDescriptorClass = AxisLabelDescriptor 

1934 axisMappingDescriptorClass = AxisMappingDescriptor 

1935 locationLabelDescriptorClass = LocationLabelDescriptor 

1936 ruleDescriptorClass = RuleDescriptor 

1937 sourceDescriptorClass = SourceDescriptor 

1938 variableFontsDescriptorClass = VariableFontDescriptor 

1939 valueAxisSubsetDescriptorClass = ValueAxisSubsetDescriptor 

1940 rangeAxisSubsetDescriptorClass = RangeAxisSubsetDescriptor 

1941 instanceDescriptorClass = InstanceDescriptor 

1942 

1943 def __init__(self, documentPath, documentObject): 

1944 self.path = documentPath 

1945 self.documentObject = documentObject 

1946 tree = ET.parse(self.path) 

1947 self.root = tree.getroot() 

1948 self.documentObject.formatVersion = self.root.attrib.get("format", "3.0") 

1949 self._axes = [] 

1950 self.rules = [] 

1951 self.sources = [] 

1952 self.instances = [] 

1953 self.axisDefaults = {} 

1954 self._strictAxisNames = True 

1955 

1956 @classmethod 

1957 def fromstring(cls, string, documentObject): 

1958 f = BytesIO(tobytes(string, encoding="utf-8")) 

1959 self = cls(f, documentObject) 

1960 self.path = None 

1961 return self 

1962 

1963 def read(self): 

1964 self.readAxes() 

1965 self.readLabels() 

1966 self.readRules() 

1967 self.readVariableFonts() 

1968 self.readSources() 

1969 self.readInstances() 

1970 self.readLib() 

1971 

1972 def readRules(self): 

1973 # we also need to read any conditions that are outside of a condition set. 

1974 rules = [] 

1975 rulesElement = self.root.find(".rules") 

1976 if rulesElement is not None: 

1977 processingValue = rulesElement.attrib.get("processing", "first") 

1978 if processingValue not in {"first", "last"}: 

1979 raise DesignSpaceDocumentError( 

1980 "<rules> processing attribute value is not valid: %r, " 

1981 "expected 'first' or 'last'" % processingValue 

1982 ) 

1983 self.documentObject.rulesProcessingLast = processingValue == "last" 

1984 for ruleElement in self.root.findall(".rules/rule"): 

1985 ruleObject = self.ruleDescriptorClass() 

1986 ruleName = ruleObject.name = ruleElement.attrib.get("name") 

1987 # read any stray conditions outside a condition set 

1988 externalConditions = self._readConditionElements( 

1989 ruleElement, 

1990 ruleName, 

1991 ) 

1992 if externalConditions: 

1993 ruleObject.conditionSets.append(externalConditions) 

1994 self.log.info( 

1995 "Found stray rule conditions outside a conditionset. " 

1996 "Wrapped them in a new conditionset." 

1997 ) 

1998 # read the conditionsets 

1999 for conditionSetElement in ruleElement.findall(".conditionset"): 

2000 conditionSet = self._readConditionElements( 

2001 conditionSetElement, 

2002 ruleName, 

2003 ) 

2004 if conditionSet is not None: 

2005 ruleObject.conditionSets.append(conditionSet) 

2006 for subElement in ruleElement.findall(".sub"): 

2007 a = subElement.attrib["name"] 

2008 b = subElement.attrib["with"] 

2009 ruleObject.subs.append((a, b)) 

2010 rules.append(ruleObject) 

2011 self.documentObject.rules = rules 

2012 

2013 def _readConditionElements(self, parentElement, ruleName=None): 

2014 cds = [] 

2015 for conditionElement in parentElement.findall(".condition"): 

2016 cd = {} 

2017 cdMin = conditionElement.attrib.get("minimum") 

2018 if cdMin is not None: 

2019 cd["minimum"] = float(cdMin) 

2020 else: 

2021 # will allow these to be None, assume axis.minimum 

2022 cd["minimum"] = None 

2023 cdMax = conditionElement.attrib.get("maximum") 

2024 if cdMax is not None: 

2025 cd["maximum"] = float(cdMax) 

2026 else: 

2027 # will allow these to be None, assume axis.maximum 

2028 cd["maximum"] = None 

2029 cd["name"] = conditionElement.attrib.get("name") 

2030 # # test for things 

2031 if cd.get("minimum") is None and cd.get("maximum") is None: 

2032 raise DesignSpaceDocumentError( 

2033 "condition missing required minimum or maximum in rule" 

2034 + (" '%s'" % ruleName if ruleName is not None else "") 

2035 ) 

2036 cds.append(cd) 

2037 return cds 

2038 

2039 def readAxes(self): 

2040 # read the axes elements, including the warp map. 

2041 axesElement = self.root.find(".axes") 

2042 if axesElement is not None and "elidedfallbackname" in axesElement.attrib: 

2043 self.documentObject.elidedFallbackName = axesElement.attrib[ 

2044 "elidedfallbackname" 

2045 ] 

2046 axisElements = self.root.findall(".axes/axis") 

2047 if not axisElements: 

2048 return 

2049 for axisElement in axisElements: 

2050 if ( 

2051 self.documentObject.formatTuple >= (5, 0) 

2052 and "values" in axisElement.attrib 

2053 ): 

2054 axisObject = self.discreteAxisDescriptorClass() 

2055 axisObject.values = [ 

2056 float(s) for s in axisElement.attrib["values"].split(" ") 

2057 ] 

2058 else: 

2059 axisObject = self.axisDescriptorClass() 

2060 axisObject.minimum = float(axisElement.attrib.get("minimum")) 

2061 axisObject.maximum = float(axisElement.attrib.get("maximum")) 

2062 axisObject.default = float(axisElement.attrib.get("default")) 

2063 axisObject.name = axisElement.attrib.get("name") 

2064 if axisElement.attrib.get("hidden", False): 

2065 axisObject.hidden = True 

2066 axisObject.tag = axisElement.attrib.get("tag") 

2067 for mapElement in axisElement.findall("map"): 

2068 a = float(mapElement.attrib["input"]) 

2069 b = float(mapElement.attrib["output"]) 

2070 axisObject.map.append((a, b)) 

2071 for labelNameElement in axisElement.findall("labelname"): 

2072 # Note: elementtree reads the "xml:lang" attribute name as 

2073 # '{http://www.w3.org/XML/1998/namespace}lang' 

2074 for key, lang in labelNameElement.items(): 

2075 if key == XML_LANG: 

2076 axisObject.labelNames[lang] = tostr(labelNameElement.text) 

2077 labelElement = axisElement.find(".labels") 

2078 if labelElement is not None: 

2079 if "ordering" in labelElement.attrib: 

2080 axisObject.axisOrdering = int(labelElement.attrib["ordering"]) 

2081 for label in labelElement.findall(".label"): 

2082 axisObject.axisLabels.append(self.readAxisLabel(label)) 

2083 self.documentObject.axes.append(axisObject) 

2084 self.axisDefaults[axisObject.name] = axisObject.default 

2085 

2086 mappingsElement = self.root.find(".axes/mappings") 

2087 self.documentObject.axisMappings = [] 

2088 if mappingsElement is not None: 

2089 for mappingElement in mappingsElement.findall("mapping"): 

2090 inputElement = mappingElement.find("input") 

2091 outputElement = mappingElement.find("output") 

2092 inputLoc = {} 

2093 outputLoc = {} 

2094 for dimElement in inputElement.findall(".dimension"): 

2095 name = dimElement.attrib["name"] 

2096 value = float(dimElement.attrib["xvalue"]) 

2097 inputLoc[name] = value 

2098 for dimElement in outputElement.findall(".dimension"): 

2099 name = dimElement.attrib["name"] 

2100 value = float(dimElement.attrib["xvalue"]) 

2101 outputLoc[name] = value 

2102 axisMappingObject = self.axisMappingDescriptorClass( 

2103 inputLocation=inputLoc, outputLocation=outputLoc 

2104 ) 

2105 self.documentObject.axisMappings.append(axisMappingObject) 

2106 

2107 def readAxisLabel(self, element: ET.Element): 

2108 xml_attrs = { 

2109 "userminimum", 

2110 "uservalue", 

2111 "usermaximum", 

2112 "name", 

2113 "elidable", 

2114 "oldersibling", 

2115 "linkeduservalue", 

2116 } 

2117 unknown_attrs = set(element.attrib) - xml_attrs 

2118 if unknown_attrs: 

2119 raise DesignSpaceDocumentError( 

2120 f"label element contains unknown attributes: {', '.join(unknown_attrs)}" 

2121 ) 

2122 

2123 name = element.get("name") 

2124 if name is None: 

2125 raise DesignSpaceDocumentError("label element must have a name attribute.") 

2126 valueStr = element.get("uservalue") 

2127 if valueStr is None: 

2128 raise DesignSpaceDocumentError( 

2129 "label element must have a uservalue attribute." 

2130 ) 

2131 value = float(valueStr) 

2132 minimumStr = element.get("userminimum") 

2133 minimum = float(minimumStr) if minimumStr is not None else None 

2134 maximumStr = element.get("usermaximum") 

2135 maximum = float(maximumStr) if maximumStr is not None else None 

2136 linkedValueStr = element.get("linkeduservalue") 

2137 linkedValue = float(linkedValueStr) if linkedValueStr is not None else None 

2138 elidable = True if element.get("elidable") == "true" else False 

2139 olderSibling = True if element.get("oldersibling") == "true" else False 

2140 labelNames = { 

2141 lang: label_name.text or "" 

2142 for label_name in element.findall("labelname") 

2143 for attr, lang in label_name.items() 

2144 if attr == XML_LANG 

2145 # Note: elementtree reads the "xml:lang" attribute name as 

2146 # '{http://www.w3.org/XML/1998/namespace}lang' 

2147 } 

2148 return self.axisLabelDescriptorClass( 

2149 name=name, 

2150 userValue=value, 

2151 userMinimum=minimum, 

2152 userMaximum=maximum, 

2153 elidable=elidable, 

2154 olderSibling=olderSibling, 

2155 linkedUserValue=linkedValue, 

2156 labelNames=labelNames, 

2157 ) 

2158 

2159 def readLabels(self): 

2160 if self.documentObject.formatTuple < (5, 0): 

2161 return 

2162 

2163 xml_attrs = {"name", "elidable", "oldersibling"} 

2164 for labelElement in self.root.findall(".labels/label"): 

2165 unknown_attrs = set(labelElement.attrib) - xml_attrs 

2166 if unknown_attrs: 

2167 raise DesignSpaceDocumentError( 

2168 f"Label element contains unknown attributes: {', '.join(unknown_attrs)}" 

2169 ) 

2170 

2171 name = labelElement.get("name") 

2172 if name is None: 

2173 raise DesignSpaceDocumentError( 

2174 "label element must have a name attribute." 

2175 ) 

2176 designLocation, userLocation = self.locationFromElement(labelElement) 

2177 if designLocation: 

2178 raise DesignSpaceDocumentError( 

2179 f'<label> element "{name}" must only have user locations (using uservalue="").' 

2180 ) 

2181 elidable = True if labelElement.get("elidable") == "true" else False 

2182 olderSibling = True if labelElement.get("oldersibling") == "true" else False 

2183 labelNames = { 

2184 lang: label_name.text or "" 

2185 for label_name in labelElement.findall("labelname") 

2186 for attr, lang in label_name.items() 

2187 if attr == XML_LANG 

2188 # Note: elementtree reads the "xml:lang" attribute name as 

2189 # '{http://www.w3.org/XML/1998/namespace}lang' 

2190 } 

2191 locationLabel = self.locationLabelDescriptorClass( 

2192 name=name, 

2193 userLocation=userLocation, 

2194 elidable=elidable, 

2195 olderSibling=olderSibling, 

2196 labelNames=labelNames, 

2197 ) 

2198 self.documentObject.locationLabels.append(locationLabel) 

2199 

2200 def readVariableFonts(self): 

2201 if self.documentObject.formatTuple < (5, 0): 

2202 return 

2203 

2204 xml_attrs = {"name", "filename"} 

2205 for variableFontElement in self.root.findall(".variable-fonts/variable-font"): 

2206 unknown_attrs = set(variableFontElement.attrib) - xml_attrs 

2207 if unknown_attrs: 

2208 raise DesignSpaceDocumentError( 

2209 f"variable-font element contains unknown attributes: {', '.join(unknown_attrs)}" 

2210 ) 

2211 

2212 name = variableFontElement.get("name") 

2213 if name is None: 

2214 raise DesignSpaceDocumentError( 

2215 "variable-font element must have a name attribute." 

2216 ) 

2217 

2218 filename = variableFontElement.get("filename") 

2219 

2220 axisSubsetsElement = variableFontElement.find(".axis-subsets") 

2221 if axisSubsetsElement is None: 

2222 raise DesignSpaceDocumentError( 

2223 "variable-font element must contain an axis-subsets element." 

2224 ) 

2225 axisSubsets = [] 

2226 for axisSubset in axisSubsetsElement.iterfind(".axis-subset"): 

2227 axisSubsets.append(self.readAxisSubset(axisSubset)) 

2228 

2229 lib = None 

2230 libElement = variableFontElement.find(".lib") 

2231 if libElement is not None: 

2232 lib = plistlib.fromtree(libElement[0]) 

2233 

2234 variableFont = self.variableFontsDescriptorClass( 

2235 name=name, 

2236 filename=filename, 

2237 axisSubsets=axisSubsets, 

2238 lib=lib, 

2239 ) 

2240 self.documentObject.variableFonts.append(variableFont) 

2241 

2242 def readAxisSubset(self, element: ET.Element): 

2243 if "uservalue" in element.attrib: 

2244 xml_attrs = {"name", "uservalue"} 

2245 unknown_attrs = set(element.attrib) - xml_attrs 

2246 if unknown_attrs: 

2247 raise DesignSpaceDocumentError( 

2248 f"axis-subset element contains unknown attributes: {', '.join(unknown_attrs)}" 

2249 ) 

2250 

2251 name = element.get("name") 

2252 if name is None: 

2253 raise DesignSpaceDocumentError( 

2254 "axis-subset element must have a name attribute." 

2255 ) 

2256 userValueStr = element.get("uservalue") 

2257 if userValueStr is None: 

2258 raise DesignSpaceDocumentError( 

2259 "The axis-subset element for a discrete subset must have a uservalue attribute." 

2260 ) 

2261 userValue = float(userValueStr) 

2262 

2263 return self.valueAxisSubsetDescriptorClass(name=name, userValue=userValue) 

2264 else: 

2265 xml_attrs = {"name", "userminimum", "userdefault", "usermaximum"} 

2266 unknown_attrs = set(element.attrib) - xml_attrs 

2267 if unknown_attrs: 

2268 raise DesignSpaceDocumentError( 

2269 f"axis-subset element contains unknown attributes: {', '.join(unknown_attrs)}" 

2270 ) 

2271 

2272 name = element.get("name") 

2273 if name is None: 

2274 raise DesignSpaceDocumentError( 

2275 "axis-subset element must have a name attribute." 

2276 ) 

2277 

2278 userMinimum = element.get("userminimum") 

2279 userDefault = element.get("userdefault") 

2280 userMaximum = element.get("usermaximum") 

2281 if ( 

2282 userMinimum is not None 

2283 and userDefault is not None 

2284 and userMaximum is not None 

2285 ): 

2286 return self.rangeAxisSubsetDescriptorClass( 

2287 name=name, 

2288 userMinimum=float(userMinimum), 

2289 userDefault=float(userDefault), 

2290 userMaximum=float(userMaximum), 

2291 ) 

2292 if all(v is None for v in (userMinimum, userDefault, userMaximum)): 

2293 return self.rangeAxisSubsetDescriptorClass(name=name) 

2294 

2295 raise DesignSpaceDocumentError( 

2296 "axis-subset element must have min/max/default values or none at all." 

2297 ) 

2298 

2299 def readSources(self): 

2300 for sourceCount, sourceElement in enumerate( 

2301 self.root.findall(".sources/source") 

2302 ): 

2303 filename = sourceElement.attrib.get("filename") 

2304 if filename is not None and self.path is not None: 

2305 sourcePath = os.path.abspath( 

2306 os.path.join(os.path.dirname(self.path), filename) 

2307 ) 

2308 else: 

2309 sourcePath = None 

2310 sourceName = sourceElement.attrib.get("name") 

2311 if sourceName is None: 

2312 # add a temporary source name 

2313 sourceName = "temp_master.%d" % (sourceCount) 

2314 sourceObject = self.sourceDescriptorClass() 

2315 sourceObject.path = sourcePath # absolute path to the ufo source 

2316 sourceObject.filename = filename # path as it is stored in the document 

2317 sourceObject.name = sourceName 

2318 familyName = sourceElement.attrib.get("familyname") 

2319 if familyName is not None: 

2320 sourceObject.familyName = familyName 

2321 styleName = sourceElement.attrib.get("stylename") 

2322 if styleName is not None: 

2323 sourceObject.styleName = styleName 

2324 for familyNameElement in sourceElement.findall("familyname"): 

2325 for key, lang in familyNameElement.items(): 

2326 if key == XML_LANG: 

2327 familyName = familyNameElement.text 

2328 sourceObject.setFamilyName(familyName, lang) 

2329 designLocation, userLocation = self.locationFromElement(sourceElement) 

2330 if userLocation: 

2331 raise DesignSpaceDocumentError( 

2332 f'<source> element "{sourceName}" must only have design locations (using xvalue="").' 

2333 ) 

2334 sourceObject.location = designLocation 

2335 layerName = sourceElement.attrib.get("layer") 

2336 if layerName is not None: 

2337 sourceObject.layerName = layerName 

2338 for libElement in sourceElement.findall(".lib"): 

2339 if libElement.attrib.get("copy") == "1": 

2340 sourceObject.copyLib = True 

2341 for groupsElement in sourceElement.findall(".groups"): 

2342 if groupsElement.attrib.get("copy") == "1": 

2343 sourceObject.copyGroups = True 

2344 for infoElement in sourceElement.findall(".info"): 

2345 if infoElement.attrib.get("copy") == "1": 

2346 sourceObject.copyInfo = True 

2347 if infoElement.attrib.get("mute") == "1": 

2348 sourceObject.muteInfo = True 

2349 for featuresElement in sourceElement.findall(".features"): 

2350 if featuresElement.attrib.get("copy") == "1": 

2351 sourceObject.copyFeatures = True 

2352 for glyphElement in sourceElement.findall(".glyph"): 

2353 glyphName = glyphElement.attrib.get("name") 

2354 if glyphName is None: 

2355 continue 

2356 if glyphElement.attrib.get("mute") == "1": 

2357 sourceObject.mutedGlyphNames.append(glyphName) 

2358 for kerningElement in sourceElement.findall(".kerning"): 

2359 if kerningElement.attrib.get("mute") == "1": 

2360 sourceObject.muteKerning = True 

2361 self.documentObject.sources.append(sourceObject) 

2362 

2363 def locationFromElement(self, element): 

2364 """Read a nested ``<location>`` element inside the given ``element``. 

2365 

2366 .. versionchanged:: 5.0 

2367 Return a tuple of (designLocation, userLocation) 

2368 """ 

2369 elementLocation = (None, None) 

2370 for locationElement in element.findall(".location"): 

2371 elementLocation = self.readLocationElement(locationElement) 

2372 break 

2373 return elementLocation 

2374 

2375 def readLocationElement(self, locationElement): 

2376 """Read a ``<location>`` element. 

2377 

2378 .. versionchanged:: 5.0 

2379 Return a tuple of (designLocation, userLocation) 

2380 """ 

2381 if self._strictAxisNames and not self.documentObject.axes: 

2382 raise DesignSpaceDocumentError("No axes defined") 

2383 userLoc = {} 

2384 designLoc = {} 

2385 for dimensionElement in locationElement.findall(".dimension"): 

2386 dimName = dimensionElement.attrib.get("name") 

2387 if self._strictAxisNames and dimName not in self.axisDefaults: 

2388 # In case the document contains no axis definitions, 

2389 self.log.warning('Location with undefined axis: "%s".', dimName) 

2390 continue 

2391 userValue = xValue = yValue = None 

2392 try: 

2393 userValue = dimensionElement.attrib.get("uservalue") 

2394 if userValue is not None: 

2395 userValue = float(userValue) 

2396 except ValueError: 

2397 self.log.warning( 

2398 "ValueError in readLocation userValue %3.3f", userValue 

2399 ) 

2400 try: 

2401 xValue = dimensionElement.attrib.get("xvalue") 

2402 if xValue is not None: 

2403 xValue = float(xValue) 

2404 except ValueError: 

2405 self.log.warning("ValueError in readLocation xValue %3.3f", xValue) 

2406 try: 

2407 yValue = dimensionElement.attrib.get("yvalue") 

2408 if yValue is not None: 

2409 yValue = float(yValue) 

2410 except ValueError: 

2411 self.log.warning("ValueError in readLocation yValue %3.3f", yValue) 

2412 if userValue is None == xValue is None: 

2413 raise DesignSpaceDocumentError( 

2414 f'Exactly one of uservalue="" or xvalue="" must be provided for location dimension "{dimName}"' 

2415 ) 

2416 if yValue is not None: 

2417 if xValue is None: 

2418 raise DesignSpaceDocumentError( 

2419 f'Missing xvalue="" for the location dimension "{dimName}"" with yvalue="{yValue}"' 

2420 ) 

2421 designLoc[dimName] = (xValue, yValue) 

2422 elif xValue is not None: 

2423 designLoc[dimName] = xValue 

2424 else: 

2425 userLoc[dimName] = userValue 

2426 return designLoc, userLoc 

2427 

2428 def readInstances(self, makeGlyphs=True, makeKerning=True, makeInfo=True): 

2429 instanceElements = self.root.findall(".instances/instance") 

2430 for instanceElement in instanceElements: 

2431 self._readSingleInstanceElement( 

2432 instanceElement, 

2433 makeGlyphs=makeGlyphs, 

2434 makeKerning=makeKerning, 

2435 makeInfo=makeInfo, 

2436 ) 

2437 

2438 def _readSingleInstanceElement( 

2439 self, instanceElement, makeGlyphs=True, makeKerning=True, makeInfo=True 

2440 ): 

2441 filename = instanceElement.attrib.get("filename") 

2442 if filename is not None and self.documentObject.path is not None: 

2443 instancePath = os.path.join( 

2444 os.path.dirname(self.documentObject.path), filename 

2445 ) 

2446 else: 

2447 instancePath = None 

2448 instanceObject = self.instanceDescriptorClass() 

2449 instanceObject.path = instancePath # absolute path to the instance 

2450 instanceObject.filename = filename # path as it is stored in the document 

2451 name = instanceElement.attrib.get("name") 

2452 if name is not None: 

2453 instanceObject.name = name 

2454 familyname = instanceElement.attrib.get("familyname") 

2455 if familyname is not None: 

2456 instanceObject.familyName = familyname 

2457 stylename = instanceElement.attrib.get("stylename") 

2458 if stylename is not None: 

2459 instanceObject.styleName = stylename 

2460 postScriptFontName = instanceElement.attrib.get("postscriptfontname") 

2461 if postScriptFontName is not None: 

2462 instanceObject.postScriptFontName = postScriptFontName 

2463 styleMapFamilyName = instanceElement.attrib.get("stylemapfamilyname") 

2464 if styleMapFamilyName is not None: 

2465 instanceObject.styleMapFamilyName = styleMapFamilyName 

2466 styleMapStyleName = instanceElement.attrib.get("stylemapstylename") 

2467 if styleMapStyleName is not None: 

2468 instanceObject.styleMapStyleName = styleMapStyleName 

2469 # read localised names 

2470 for styleNameElement in instanceElement.findall("stylename"): 

2471 for key, lang in styleNameElement.items(): 

2472 if key == XML_LANG: 

2473 styleName = styleNameElement.text 

2474 instanceObject.setStyleName(styleName, lang) 

2475 for familyNameElement in instanceElement.findall("familyname"): 

2476 for key, lang in familyNameElement.items(): 

2477 if key == XML_LANG: 

2478 familyName = familyNameElement.text 

2479 instanceObject.setFamilyName(familyName, lang) 

2480 for styleMapStyleNameElement in instanceElement.findall("stylemapstylename"): 

2481 for key, lang in styleMapStyleNameElement.items(): 

2482 if key == XML_LANG: 

2483 styleMapStyleName = styleMapStyleNameElement.text 

2484 instanceObject.setStyleMapStyleName(styleMapStyleName, lang) 

2485 for styleMapFamilyNameElement in instanceElement.findall("stylemapfamilyname"): 

2486 for key, lang in styleMapFamilyNameElement.items(): 

2487 if key == XML_LANG: 

2488 styleMapFamilyName = styleMapFamilyNameElement.text 

2489 instanceObject.setStyleMapFamilyName(styleMapFamilyName, lang) 

2490 designLocation, userLocation = self.locationFromElement(instanceElement) 

2491 locationLabel = instanceElement.attrib.get("location") 

2492 if (designLocation or userLocation) and locationLabel is not None: 

2493 raise DesignSpaceDocumentError( 

2494 'instance element must have at most one of the location="..." attribute or the nested location element' 

2495 ) 

2496 instanceObject.locationLabel = locationLabel 

2497 instanceObject.userLocation = userLocation or {} 

2498 instanceObject.designLocation = designLocation or {} 

2499 for glyphElement in instanceElement.findall(".glyphs/glyph"): 

2500 self.readGlyphElement(glyphElement, instanceObject) 

2501 for infoElement in instanceElement.findall("info"): 

2502 self.readInfoElement(infoElement, instanceObject) 

2503 for libElement in instanceElement.findall("lib"): 

2504 self.readLibElement(libElement, instanceObject) 

2505 self.documentObject.instances.append(instanceObject) 

2506 

2507 def readLibElement(self, libElement, instanceObject): 

2508 """Read the lib element for the given instance.""" 

2509 instanceObject.lib = plistlib.fromtree(libElement[0]) 

2510 

2511 def readInfoElement(self, infoElement, instanceObject): 

2512 """Read the info element.""" 

2513 instanceObject.info = True 

2514 

2515 def readGlyphElement(self, glyphElement, instanceObject): 

2516 """ 

2517 Read the glyph element, which could look like either one of these: 

2518 

2519 .. code-block:: xml 

2520 

2521 <glyph name="b" unicode="0x62"/> 

2522 

2523 <glyph name="b"/> 

2524 

2525 <glyph name="b"> 

2526 <master location="location-token-bbb" source="master-token-aaa2"/> 

2527 <master glyphname="b.alt1" location="location-token-ccc" source="master-token-aaa3"/> 

2528 <note> 

2529 This is an instance from an anisotropic interpolation. 

2530 </note> 

2531 </glyph> 

2532 """ 

2533 glyphData = {} 

2534 glyphName = glyphElement.attrib.get("name") 

2535 if glyphName is None: 

2536 raise DesignSpaceDocumentError("Glyph object without name attribute") 

2537 mute = glyphElement.attrib.get("mute") 

2538 if mute == "1": 

2539 glyphData["mute"] = True 

2540 # unicode 

2541 unicodes = glyphElement.attrib.get("unicode") 

2542 if unicodes is not None: 

2543 try: 

2544 unicodes = [int(u, 16) for u in unicodes.split(" ")] 

2545 glyphData["unicodes"] = unicodes 

2546 except ValueError: 

2547 raise DesignSpaceDocumentError( 

2548 "unicode values %s are not integers" % unicodes 

2549 ) 

2550 

2551 for noteElement in glyphElement.findall(".note"): 

2552 glyphData["note"] = noteElement.text 

2553 break 

2554 designLocation, userLocation = self.locationFromElement(glyphElement) 

2555 if userLocation: 

2556 raise DesignSpaceDocumentError( 

2557 f'<glyph> element "{glyphName}" must only have design locations (using xvalue="").' 

2558 ) 

2559 if designLocation is not None: 

2560 glyphData["instanceLocation"] = designLocation 

2561 glyphSources = None 

2562 for masterElement in glyphElement.findall(".masters/master"): 

2563 fontSourceName = masterElement.attrib.get("source") 

2564 designLocation, userLocation = self.locationFromElement(masterElement) 

2565 if userLocation: 

2566 raise DesignSpaceDocumentError( 

2567 f'<master> element "{fontSourceName}" must only have design locations (using xvalue="").' 

2568 ) 

2569 masterGlyphName = masterElement.attrib.get("glyphname") 

2570 if masterGlyphName is None: 

2571 # if we don't read a glyphname, use the one we have 

2572 masterGlyphName = glyphName 

2573 d = dict( 

2574 font=fontSourceName, location=designLocation, glyphName=masterGlyphName 

2575 ) 

2576 if glyphSources is None: 

2577 glyphSources = [] 

2578 glyphSources.append(d) 

2579 if glyphSources is not None: 

2580 glyphData["masters"] = glyphSources 

2581 instanceObject.glyphs[glyphName] = glyphData 

2582 

2583 def readLib(self): 

2584 """Read the lib element for the whole document.""" 

2585 for libElement in self.root.findall(".lib"): 

2586 self.documentObject.lib = plistlib.fromtree(libElement[0]) 

2587 

2588 

2589class DesignSpaceDocument(LogMixin, AsDictMixin): 

2590 """The DesignSpaceDocument object can read and write ``.designspace`` data. 

2591 It imports the axes, sources, variable fonts and instances to very basic 

2592 **descriptor** objects that store the data in attributes. Data is added to 

2593 the document by creating such descriptor objects, filling them with data 

2594 and then adding them to the document. This makes it easy to integrate this 

2595 object in different contexts. 

2596 

2597 The **DesignSpaceDocument** object can be subclassed to work with 

2598 different objects, as long as they have the same attributes. Reader and 

2599 Writer objects can be subclassed as well. 

2600 

2601 **Note:** Python attribute names are usually camelCased, the 

2602 corresponding `XML <document-xml-structure>`_ attributes are usually 

2603 all lowercase. 

2604 

2605 .. code:: python 

2606 

2607 from fontTools.designspaceLib import DesignSpaceDocument 

2608 doc = DesignSpaceDocument.fromfile("some/path/to/my.designspace") 

2609 doc.formatVersion 

2610 doc.elidedFallbackName 

2611 doc.axes 

2612 doc.axisMappings 

2613 doc.locationLabels 

2614 doc.rules 

2615 doc.rulesProcessingLast 

2616 doc.sources 

2617 doc.variableFonts 

2618 doc.instances 

2619 doc.lib 

2620 

2621 """ 

2622 

2623 def __init__(self, readerClass=None, writerClass=None): 

2624 self.path = None 

2625 """String, optional. When the document is read from the disk, this is 

2626 the full path that was given to :meth:`read` or :meth:`fromfile`. 

2627 """ 

2628 self.filename = None 

2629 """String, optional. When the document is read from the disk, this is 

2630 its original file name, i.e. the last part of its path. 

2631 

2632 When the document is produced by a Python script and still only exists 

2633 in memory, the producing script can write here an indication of a 

2634 possible "good" filename, in case one wants to save the file somewhere. 

2635 """ 

2636 

2637 self.formatVersion: Optional[str] = None 

2638 """Format version for this document, as a string. E.g. "4.0" """ 

2639 

2640 self.elidedFallbackName: Optional[str] = None 

2641 """STAT Style Attributes Header field ``elidedFallbackNameID``. 

2642 

2643 See: `OTSpec STAT Style Attributes Header <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#style-attributes-header>`_ 

2644 

2645 .. versionadded:: 5.0 

2646 """ 

2647 

2648 self.axes: List[Union[AxisDescriptor, DiscreteAxisDescriptor]] = [] 

2649 """List of this document's axes.""" 

2650 

2651 self.axisMappings: List[AxisMappingDescriptor] = [] 

2652 """List of this document's axis mappings.""" 

2653 

2654 self.locationLabels: List[LocationLabelDescriptor] = [] 

2655 """List of this document's STAT format 4 labels. 

2656 

2657 .. versionadded:: 5.0""" 

2658 self.rules: List[RuleDescriptor] = [] 

2659 """List of this document's rules.""" 

2660 self.rulesProcessingLast: bool = False 

2661 """This flag indicates whether the substitution rules should be applied 

2662 before or after other glyph substitution features. 

2663 

2664 - False: before 

2665 - True: after. 

2666 

2667 Default is False. For new projects, you probably want True. See 

2668 the following issues for more information: 

2669 `fontTools#1371 <https://github.com/fonttools/fonttools/issues/1371#issuecomment-590214572>`__ 

2670 `fontTools#2050 <https://github.com/fonttools/fonttools/issues/2050#issuecomment-678691020>`__ 

2671 

2672 If you want to use a different feature altogether, e.g. ``calt``, 

2673 use the lib key ``com.github.fonttools.varLib.featureVarsFeatureTag`` 

2674 

2675 .. code:: xml 

2676 

2677 <lib> 

2678 <dict> 

2679 <key>com.github.fonttools.varLib.featureVarsFeatureTag</key> 

2680 <string>calt</string> 

2681 </dict> 

2682 </lib> 

2683 """ 

2684 self.sources: List[SourceDescriptor] = [] 

2685 """List of this document's sources.""" 

2686 self.variableFonts: List[VariableFontDescriptor] = [] 

2687 """List of this document's variable fonts. 

2688 

2689 .. versionadded:: 5.0""" 

2690 self.instances: List[InstanceDescriptor] = [] 

2691 """List of this document's instances.""" 

2692 self.lib: Dict = {} 

2693 """User defined, custom data associated with the whole document. 

2694 

2695 Use reverse-DNS notation to identify your own data. 

2696 Respect the data stored by others. 

2697 """ 

2698 

2699 self.default: Optional[str] = None 

2700 """Name of the default master. 

2701 

2702 This attribute is updated by the :meth:`findDefault` 

2703 """ 

2704 

2705 if readerClass is not None: 

2706 self.readerClass = readerClass 

2707 else: 

2708 self.readerClass = BaseDocReader 

2709 if writerClass is not None: 

2710 self.writerClass = writerClass 

2711 else: 

2712 self.writerClass = BaseDocWriter 

2713 

2714 @classmethod 

2715 def fromfile(cls, path, readerClass=None, writerClass=None): 

2716 """Read a designspace file from ``path`` and return a new instance of 

2717 :class:. 

2718 """ 

2719 self = cls(readerClass=readerClass, writerClass=writerClass) 

2720 self.read(path) 

2721 return self 

2722 

2723 @classmethod 

2724 def fromstring(cls, string, readerClass=None, writerClass=None): 

2725 self = cls(readerClass=readerClass, writerClass=writerClass) 

2726 reader = self.readerClass.fromstring(string, self) 

2727 reader.read() 

2728 if self.sources: 

2729 self.findDefault() 

2730 return self 

2731 

2732 def tostring(self, encoding=None): 

2733 """Returns the designspace as a string. Default encoding ``utf-8``.""" 

2734 if encoding is str or (encoding is not None and encoding.lower() == "unicode"): 

2735 f = StringIO() 

2736 xml_declaration = False 

2737 elif encoding is None or encoding == "utf-8": 

2738 f = BytesIO() 

2739 encoding = "UTF-8" 

2740 xml_declaration = True 

2741 else: 

2742 raise ValueError("unsupported encoding: '%s'" % encoding) 

2743 writer = self.writerClass(f, self) 

2744 writer.write(encoding=encoding, xml_declaration=xml_declaration) 

2745 return f.getvalue() 

2746 

2747 def read(self, path): 

2748 """Read a designspace file from ``path`` and populates the fields of 

2749 ``self`` with the data. 

2750 """ 

2751 if hasattr(path, "__fspath__"): # support os.PathLike objects 

2752 path = path.__fspath__() 

2753 self.path = path 

2754 self.filename = os.path.basename(path) 

2755 reader = self.readerClass(path, self) 

2756 reader.read() 

2757 if self.sources: 

2758 self.findDefault() 

2759 

2760 def write(self, path): 

2761 """Write this designspace to ``path``.""" 

2762 if hasattr(path, "__fspath__"): # support os.PathLike objects 

2763 path = path.__fspath__() 

2764 self.path = path 

2765 self.filename = os.path.basename(path) 

2766 self.updatePaths() 

2767 writer = self.writerClass(path, self) 

2768 writer.write() 

2769 

2770 def _posixRelativePath(self, otherPath): 

2771 relative = os.path.relpath(otherPath, os.path.dirname(self.path)) 

2772 return posix(relative) 

2773 

2774 def updatePaths(self): 

2775 """ 

2776 Right before we save we need to identify and respond to the following situations: 

2777 In each descriptor, we have to do the right thing for the filename attribute. 

2778 

2779 :: 

2780 

2781 case 1. 

2782 descriptor.filename == None 

2783 descriptor.path == None 

2784 

2785 -- action: 

2786 write as is, descriptors will not have a filename attr. 

2787 useless, but no reason to interfere. 

2788 

2789 

2790 case 2. 

2791 descriptor.filename == "../something" 

2792 descriptor.path == None 

2793 

2794 -- action: 

2795 write as is. The filename attr should not be touched. 

2796 

2797 

2798 case 3. 

2799 descriptor.filename == None 

2800 descriptor.path == "~/absolute/path/there" 

2801 

2802 -- action: 

2803 calculate the relative path for filename. 

2804 We're not overwriting some other value for filename, it should be fine 

2805 

2806 

2807 case 4. 

2808 descriptor.filename == '../somewhere' 

2809 descriptor.path == "~/absolute/path/there" 

2810 

2811 -- action: 

2812 there is a conflict between the given filename, and the path. 

2813 So we know where the file is relative to the document. 

2814 Can't guess why they're different, we just choose for path to be correct and update filename. 

2815 """ 

2816 assert self.path is not None 

2817 for descriptor in self.sources + self.instances: 

2818 if descriptor.path is not None: 

2819 # case 3 and 4: filename gets updated and relativized 

2820 descriptor.filename = self._posixRelativePath(descriptor.path) 

2821 

2822 def addSource(self, sourceDescriptor: SourceDescriptor): 

2823 """Add the given ``sourceDescriptor`` to ``doc.sources``.""" 

2824 self.sources.append(sourceDescriptor) 

2825 

2826 def addSourceDescriptor(self, **kwargs): 

2827 """Instantiate a new :class:`SourceDescriptor` using the given 

2828 ``kwargs`` and add it to ``doc.sources``. 

2829 """ 

2830 source = self.writerClass.sourceDescriptorClass(**kwargs) 

2831 self.addSource(source) 

2832 return source 

2833 

2834 def addInstance(self, instanceDescriptor: InstanceDescriptor): 

2835 """Add the given ``instanceDescriptor`` to :attr:`instances`.""" 

2836 self.instances.append(instanceDescriptor) 

2837 

2838 def addInstanceDescriptor(self, **kwargs): 

2839 """Instantiate a new :class:`InstanceDescriptor` using the given 

2840 ``kwargs`` and add it to :attr:`instances`. 

2841 """ 

2842 instance = self.writerClass.instanceDescriptorClass(**kwargs) 

2843 self.addInstance(instance) 

2844 return instance 

2845 

2846 def addAxis(self, axisDescriptor: Union[AxisDescriptor, DiscreteAxisDescriptor]): 

2847 """Add the given ``axisDescriptor`` to :attr:`axes`.""" 

2848 self.axes.append(axisDescriptor) 

2849 

2850 def addAxisDescriptor(self, **kwargs): 

2851 """Instantiate a new :class:`AxisDescriptor` using the given 

2852 ``kwargs`` and add it to :attr:`axes`. 

2853 

2854 The axis will be and instance of :class:`DiscreteAxisDescriptor` if 

2855 the ``kwargs`` provide a ``value``, or a :class:`AxisDescriptor` otherwise. 

2856 """ 

2857 if "values" in kwargs: 

2858 axis = self.writerClass.discreteAxisDescriptorClass(**kwargs) 

2859 else: 

2860 axis = self.writerClass.axisDescriptorClass(**kwargs) 

2861 self.addAxis(axis) 

2862 return axis 

2863 

2864 def addAxisMapping(self, axisMappingDescriptor: AxisMappingDescriptor): 

2865 """Add the given ``axisMappingDescriptor`` to :attr:`axisMappings`.""" 

2866 self.axisMappings.append(axisMappingDescriptor) 

2867 

2868 def addAxisMappingDescriptor(self, **kwargs): 

2869 """Instantiate a new :class:`AxisMappingDescriptor` using the given 

2870 ``kwargs`` and add it to :attr:`rules`. 

2871 """ 

2872 axisMapping = self.writerClass.axisMappingDescriptorClass(**kwargs) 

2873 self.addAxisMapping(axisMapping) 

2874 return axisMapping 

2875 

2876 def addRule(self, ruleDescriptor: RuleDescriptor): 

2877 """Add the given ``ruleDescriptor`` to :attr:`rules`.""" 

2878 self.rules.append(ruleDescriptor) 

2879 

2880 def addRuleDescriptor(self, **kwargs): 

2881 """Instantiate a new :class:`RuleDescriptor` using the given 

2882 ``kwargs`` and add it to :attr:`rules`. 

2883 """ 

2884 rule = self.writerClass.ruleDescriptorClass(**kwargs) 

2885 self.addRule(rule) 

2886 return rule 

2887 

2888 def addVariableFont(self, variableFontDescriptor: VariableFontDescriptor): 

2889 """Add the given ``variableFontDescriptor`` to :attr:`variableFonts`. 

2890 

2891 .. versionadded:: 5.0 

2892 """ 

2893 self.variableFonts.append(variableFontDescriptor) 

2894 

2895 def addVariableFontDescriptor(self, **kwargs): 

2896 """Instantiate a new :class:`VariableFontDescriptor` using the given 

2897 ``kwargs`` and add it to :attr:`variableFonts`. 

2898 

2899 .. versionadded:: 5.0 

2900 """ 

2901 variableFont = self.writerClass.variableFontDescriptorClass(**kwargs) 

2902 self.addVariableFont(variableFont) 

2903 return variableFont 

2904 

2905 def addLocationLabel(self, locationLabelDescriptor: LocationLabelDescriptor): 

2906 """Add the given ``locationLabelDescriptor`` to :attr:`locationLabels`. 

2907 

2908 .. versionadded:: 5.0 

2909 """ 

2910 self.locationLabels.append(locationLabelDescriptor) 

2911 

2912 def addLocationLabelDescriptor(self, **kwargs): 

2913 """Instantiate a new :class:`LocationLabelDescriptor` using the given 

2914 ``kwargs`` and add it to :attr:`locationLabels`. 

2915 

2916 .. versionadded:: 5.0 

2917 """ 

2918 locationLabel = self.writerClass.locationLabelDescriptorClass(**kwargs) 

2919 self.addLocationLabel(locationLabel) 

2920 return locationLabel 

2921 

2922 def newDefaultLocation(self): 

2923 """Return a dict with the default location in design space coordinates.""" 

2924 # Without OrderedDict, output XML would be non-deterministic. 

2925 # https://github.com/LettError/designSpaceDocument/issues/10 

2926 loc = collections.OrderedDict() 

2927 for axisDescriptor in self.axes: 

2928 loc[axisDescriptor.name] = axisDescriptor.map_forward( 

2929 axisDescriptor.default 

2930 ) 

2931 return loc 

2932 

2933 def labelForUserLocation( 

2934 self, userLocation: SimpleLocationDict 

2935 ) -> Optional[LocationLabelDescriptor]: 

2936 """Return the :class:`LocationLabel` that matches the given 

2937 ``userLocation``, or ``None`` if no such label exists. 

2938 

2939 .. versionadded:: 5.0 

2940 """ 

2941 return next( 

2942 ( 

2943 label 

2944 for label in self.locationLabels 

2945 if label.userLocation == userLocation 

2946 ), 

2947 None, 

2948 ) 

2949 

2950 def updateFilenameFromPath(self, masters=True, instances=True, force=False): 

2951 """Set a descriptor filename attr from the path and this document path. 

2952 

2953 If the filename attribute is not None: skip it. 

2954 """ 

2955 if masters: 

2956 for descriptor in self.sources: 

2957 if descriptor.filename is not None and not force: 

2958 continue 

2959 if self.path is not None: 

2960 descriptor.filename = self._posixRelativePath(descriptor.path) 

2961 if instances: 

2962 for descriptor in self.instances: 

2963 if descriptor.filename is not None and not force: 

2964 continue 

2965 if self.path is not None: 

2966 descriptor.filename = self._posixRelativePath(descriptor.path) 

2967 

2968 def newAxisDescriptor(self): 

2969 """Ask the writer class to make us a new axisDescriptor.""" 

2970 return self.writerClass.getAxisDecriptor() 

2971 

2972 def newSourceDescriptor(self): 

2973 """Ask the writer class to make us a new sourceDescriptor.""" 

2974 return self.writerClass.getSourceDescriptor() 

2975 

2976 def newInstanceDescriptor(self): 

2977 """Ask the writer class to make us a new instanceDescriptor.""" 

2978 return self.writerClass.getInstanceDescriptor() 

2979 

2980 def getAxisOrder(self): 

2981 """Return a list of axis names, in the same order as defined in the document.""" 

2982 names = [] 

2983 for axisDescriptor in self.axes: 

2984 names.append(axisDescriptor.name) 

2985 return names 

2986 

2987 def getAxis(self, name: str) -> AxisDescriptor | DiscreteAxisDescriptor | None: 

2988 """Return the axis with the given ``name``, or ``None`` if no such axis exists.""" 

2989 return next((axis for axis in self.axes if axis.name == name), None) 

2990 

2991 def getAxisByTag(self, tag: str) -> AxisDescriptor | DiscreteAxisDescriptor | None: 

2992 """Return the axis with the given ``tag``, or ``None`` if no such axis exists.""" 

2993 return next((axis for axis in self.axes if axis.tag == tag), None) 

2994 

2995 def getLocationLabel(self, name: str) -> Optional[LocationLabelDescriptor]: 

2996 """Return the top-level location label with the given ``name``, or 

2997 ``None`` if no such label exists. 

2998 

2999 .. versionadded:: 5.0 

3000 """ 

3001 for label in self.locationLabels: 

3002 if label.name == name: 

3003 return label 

3004 return None 

3005 

3006 def map_forward(self, userLocation: SimpleLocationDict) -> SimpleLocationDict: 

3007 """Map a user location to a design location. 

3008 

3009 Assume that missing coordinates are at the default location for that axis. 

3010 

3011 Note: the output won't be anisotropic, only the xvalue is set. 

3012 

3013 .. versionadded:: 5.0 

3014 """ 

3015 return { 

3016 axis.name: axis.map_forward(userLocation.get(axis.name, axis.default)) 

3017 for axis in self.axes 

3018 } 

3019 

3020 def map_backward( 

3021 self, designLocation: AnisotropicLocationDict 

3022 ) -> SimpleLocationDict: 

3023 """Map a design location to a user location. 

3024 

3025 Assume that missing coordinates are at the default location for that axis. 

3026 

3027 When the input has anisotropic locations, only the xvalue is used. 

3028 

3029 .. versionadded:: 5.0 

3030 """ 

3031 return { 

3032 axis.name: ( 

3033 axis.map_backward(designLocation[axis.name]) 

3034 if axis.name in designLocation 

3035 else axis.default 

3036 ) 

3037 for axis in self.axes 

3038 } 

3039 

3040 def findDefault(self): 

3041 """Set and return SourceDescriptor at the default location or None. 

3042 

3043 The default location is the set of all `default` values in user space 

3044 of all axes. 

3045 

3046 This function updates the document's :attr:`default` value. 

3047 

3048 .. versionchanged:: 5.0 

3049 Allow the default source to not specify some of the axis values, and 

3050 they are assumed to be the default. 

3051 See :meth:`SourceDescriptor.getFullDesignLocation()` 

3052 """ 

3053 self.default = None 

3054 

3055 # Convert the default location from user space to design space before comparing 

3056 # it against the SourceDescriptor locations (always in design space). 

3057 defaultDesignLocation = self.newDefaultLocation() 

3058 

3059 for sourceDescriptor in self.sources: 

3060 if sourceDescriptor.getFullDesignLocation(self) == defaultDesignLocation: 

3061 self.default = sourceDescriptor 

3062 return sourceDescriptor 

3063 

3064 return None 

3065 

3066 def normalizeLocation(self, location): 

3067 """Return a dict with normalized axis values.""" 

3068 from fontTools.varLib.models import normalizeValue 

3069 

3070 new = {} 

3071 for axis in self.axes: 

3072 if axis.name not in location: 

3073 # skipping this dimension it seems 

3074 continue 

3075 value = location[axis.name] 

3076 # 'anisotropic' location, take first coord only 

3077 if isinstance(value, tuple): 

3078 value = value[0] 

3079 triple = [ 

3080 axis.map_forward(v) for v in (axis.minimum, axis.default, axis.maximum) 

3081 ] 

3082 new[axis.name] = normalizeValue(value, triple) 

3083 return new 

3084 

3085 def normalize(self): 

3086 """ 

3087 Normalise the geometry of this designspace: 

3088 

3089 - scale all the locations of all masters and instances to the -1 - 0 - 1 value. 

3090 - we need the axis data to do the scaling, so we do those last. 

3091 """ 

3092 # masters 

3093 for item in self.sources: 

3094 item.location = self.normalizeLocation(item.location) 

3095 # instances 

3096 for item in self.instances: 

3097 # glyph masters for this instance 

3098 for _, glyphData in item.glyphs.items(): 

3099 glyphData["instanceLocation"] = self.normalizeLocation( 

3100 glyphData["instanceLocation"] 

3101 ) 

3102 for glyphMaster in glyphData["masters"]: 

3103 glyphMaster["location"] = self.normalizeLocation( 

3104 glyphMaster["location"] 

3105 ) 

3106 item.location = self.normalizeLocation(item.location) 

3107 # the axes 

3108 for axis in self.axes: 

3109 # scale the map first 

3110 newMap = [] 

3111 for inputValue, outputValue in axis.map: 

3112 newOutputValue = self.normalizeLocation({axis.name: outputValue}).get( 

3113 axis.name 

3114 ) 

3115 newMap.append((inputValue, newOutputValue)) 

3116 if newMap: 

3117 axis.map = newMap 

3118 # finally the axis values 

3119 minimum = self.normalizeLocation({axis.name: axis.minimum}).get(axis.name) 

3120 maximum = self.normalizeLocation({axis.name: axis.maximum}).get(axis.name) 

3121 default = self.normalizeLocation({axis.name: axis.default}).get(axis.name) 

3122 # and set them in the axis.minimum 

3123 axis.minimum = minimum 

3124 axis.maximum = maximum 

3125 axis.default = default 

3126 # now the rules 

3127 for rule in self.rules: 

3128 newConditionSets = [] 

3129 for conditions in rule.conditionSets: 

3130 newConditions = [] 

3131 for cond in conditions: 

3132 if cond.get("minimum") is not None: 

3133 minimum = self.normalizeLocation( 

3134 {cond["name"]: cond["minimum"]} 

3135 ).get(cond["name"]) 

3136 else: 

3137 minimum = None 

3138 if cond.get("maximum") is not None: 

3139 maximum = self.normalizeLocation( 

3140 {cond["name"]: cond["maximum"]} 

3141 ).get(cond["name"]) 

3142 else: 

3143 maximum = None 

3144 newConditions.append( 

3145 dict(name=cond["name"], minimum=minimum, maximum=maximum) 

3146 ) 

3147 newConditionSets.append(newConditions) 

3148 rule.conditionSets = newConditionSets 

3149 

3150 def loadSourceFonts(self, opener, **kwargs): 

3151 """Ensure SourceDescriptor.font attributes are loaded, and return list of fonts. 

3152 

3153 Takes a callable which initializes a new font object (e.g. TTFont, or 

3154 defcon.Font, etc.) from the SourceDescriptor.path, and sets the 

3155 SourceDescriptor.font attribute. 

3156 If the font attribute is already not None, it is not loaded again. 

3157 Fonts with the same path are only loaded once and shared among SourceDescriptors. 

3158 

3159 For example, to load UFO sources using defcon: 

3160 

3161 designspace = DesignSpaceDocument.fromfile("path/to/my.designspace") 

3162 designspace.loadSourceFonts(defcon.Font) 

3163 

3164 Or to load masters as FontTools binary fonts, including extra options: 

3165 

3166 designspace.loadSourceFonts(ttLib.TTFont, recalcBBoxes=False) 

3167 

3168 Args: 

3169 opener (Callable): takes one required positional argument, the source.path, 

3170 and an optional list of keyword arguments, and returns a new font object 

3171 loaded from the path. 

3172 **kwargs: extra options passed on to the opener function. 

3173 

3174 Returns: 

3175 List of font objects in the order they appear in the sources list. 

3176 """ 

3177 # we load fonts with the same source.path only once 

3178 loaded = {} 

3179 fonts = [] 

3180 for source in self.sources: 

3181 if source.font is not None: # font already loaded 

3182 fonts.append(source.font) 

3183 continue 

3184 if source.path in loaded: 

3185 source.font = loaded[source.path] 

3186 else: 

3187 if source.path is None: 

3188 raise DesignSpaceDocumentError( 

3189 "Designspace source '%s' has no 'path' attribute" 

3190 % (source.name or "<Unknown>") 

3191 ) 

3192 source.font = opener(source.path, **kwargs) 

3193 loaded[source.path] = source.font 

3194 fonts.append(source.font) 

3195 return fonts 

3196 

3197 @property 

3198 def formatTuple(self): 

3199 """Return the formatVersion as a tuple of (major, minor). 

3200 

3201 .. versionadded:: 5.0 

3202 """ 

3203 if self.formatVersion is None: 

3204 return (5, 0) 

3205 numbers = (int(i) for i in self.formatVersion.split(".")) 

3206 major = next(numbers) 

3207 minor = next(numbers, 0) 

3208 return (major, minor) 

3209 

3210 def getVariableFonts(self) -> List[VariableFontDescriptor]: 

3211 """Return all variable fonts defined in this document, or implicit 

3212 variable fonts that can be built from the document's continuous axes. 

3213 

3214 In the case of Designspace documents before version 5, the whole 

3215 document was implicitly describing a variable font that covers the 

3216 whole space. 

3217 

3218 In version 5 and above documents, there can be as many variable fonts 

3219 as there are locations on discrete axes. 

3220 

3221 .. seealso:: :func:`splitInterpolable` 

3222 

3223 .. versionadded:: 5.0 

3224 """ 

3225 if self.variableFonts: 

3226 return self.variableFonts 

3227 

3228 variableFonts = [] 

3229 discreteAxes = [] 

3230 rangeAxisSubsets: List[ 

3231 Union[RangeAxisSubsetDescriptor, ValueAxisSubsetDescriptor] 

3232 ] = [] 

3233 for axis in self.axes: 

3234 if hasattr(axis, "values"): 

3235 # Mypy doesn't support narrowing union types via hasattr() 

3236 # TODO(Python 3.10): use TypeGuard 

3237 # https://mypy.readthedocs.io/en/stable/type_narrowing.html 

3238 axis = cast(DiscreteAxisDescriptor, axis) 

3239 discreteAxes.append(axis) # type: ignore 

3240 else: 

3241 rangeAxisSubsets.append(RangeAxisSubsetDescriptor(name=axis.name)) 

3242 valueCombinations = itertools.product(*[axis.values for axis in discreteAxes]) 

3243 for values in valueCombinations: 

3244 basename = None 

3245 if self.filename is not None: 

3246 basename = os.path.splitext(self.filename)[0] + "-VF" 

3247 if self.path is not None: 

3248 basename = os.path.splitext(os.path.basename(self.path))[0] + "-VF" 

3249 if basename is None: 

3250 basename = "VF" 

3251 axisNames = "".join( 

3252 [f"-{axis.tag}{value}" for axis, value in zip(discreteAxes, values)] 

3253 ) 

3254 variableFonts.append( 

3255 VariableFontDescriptor( 

3256 name=f"{basename}{axisNames}", 

3257 axisSubsets=rangeAxisSubsets 

3258 + [ 

3259 ValueAxisSubsetDescriptor(name=axis.name, userValue=value) 

3260 for axis, value in zip(discreteAxes, values) 

3261 ], 

3262 ) 

3263 ) 

3264 return variableFonts 

3265 

3266 def deepcopyExceptFonts(self): 

3267 """Allow deep-copying a DesignSpace document without deep-copying 

3268 attached UFO fonts or TTFont objects. The :attr:`font` attribute 

3269 is shared by reference between the original and the copy. 

3270 

3271 .. versionadded:: 5.0 

3272 """ 

3273 fonts = [source.font for source in self.sources] 

3274 try: 

3275 for source in self.sources: 

3276 source.font = None 

3277 res = copy.deepcopy(self) 

3278 for source, font in zip(res.sources, fonts): 

3279 source.font = font 

3280 return res 

3281 finally: 

3282 for source, font in zip(self.sources, fonts): 

3283 source.font = font