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
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-14 15:55 +0200
1from __future__ import annotations
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
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
18"""
19 designSpaceDocument
21 - read and write designspace files
22"""
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]
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"
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
60def posixpath_property(private_name):
61 """Generate a propery that holds a path always using forward slashes."""
63 def getter(self):
64 # Normal getter
65 return getattr(self, private_name)
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)
73 return property(getter, setter)
76class DesignSpaceDocumentError(Exception):
77 def __init__(self, msg, obj=None):
78 self.msg = msg
79 self.obj = obj
81 def __str__(self):
82 return str(self.msg) + (": %r" % self.obj if self.obj is not None else "")
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
99class SimpleDescriptor(AsDictMixin):
100 """Containers for a bunch of attributes"""
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 )
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)"
124class SourceDescriptor(SimpleDescriptor):
125 """Simple container for data related to the source
127 .. code:: python
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)
142 """
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 ]
162 filename = posixpath_property("_filename")
163 path = posixpath_property("_path")
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**.
189 MutatorMath + VarLib.
190 """
191 self.path = path
192 """The absolute path, calculated from filename."""
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``).
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 """
206 self.name = name
207 """string. Optional. Unique identifier name for this source.
209 MutatorMath + varLib.
210 """
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.
217 MutatorMath + varLib.
219 This may be only part of the full design location.
220 See :meth:`getFullDesignLocation()`
222 .. versionadded:: 5.0
223 """
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.
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.
241 varLib.
242 """
243 self.localisedFamilyName = localisedFamilyName or {}
244 """dict. A dictionary of localised family name strings, keyed by
245 language code.
247 If present, will be used to build localized names for all instances.
249 .. versionadded:: 5.0
250 """
252 self.copyLib = copyLib
253 """bool. Indicates if the contents of the font.lib need to
254 be copied to the instances.
256 MutatorMath.
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.
264 MutatorMath.
266 .. deprecated:: 5.0
267 """
268 self.copyGroups = copyGroups
269 """bool. Indicates if the groups need to be copied to the
270 instances.
272 MutatorMath.
274 .. deprecated:: 5.0
275 """
276 self.copyFeatures = copyFeatures
277 """bool. Indicates if the feature text needs to be
278 copied to the instances.
280 MutatorMath.
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).
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.
294 MutatorMath only.
295 """
296 self.mutedGlyphNames = mutedGlyphNames or []
297 """list. Glyphnames that need to be muted in the
298 instances.
300 MutatorMath only.
301 """
303 @property
304 def location(self):
305 """dict. Axis values for this source, in design space coordinates.
307 MutatorMath + varLib.
309 .. deprecated:: 5.0
310 Use the more explicit alias for this property :attr:`designLocation`.
311 """
312 return self.designLocation
314 @location.setter
315 def location(self, location: Optional[AnisotropicLocationDict]):
316 self.designLocation = location or {}
318 def setFamilyName(self, familyName, languageCode="en"):
319 """Setter for :attr:`localisedFamilyName`
321 .. versionadded:: 5.0
322 """
323 self.localisedFamilyName[languageCode] = tostr(familyName)
325 def getFamilyName(self, languageCode="en"):
326 """Getter for :attr:`localisedFamilyName`
328 .. versionadded:: 5.0
329 """
330 return self.localisedFamilyName.get(languageCode)
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.
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
349class RuleDescriptor(SimpleDescriptor):
350 """Represents the rule descriptor element: a set of glyph substitutions to
351 trigger conditionally in some parts of the designspace.
353 .. code:: python
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"))
361 .. code:: xml
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 """
377 _attrs = ["name", "conditionSets", "subs"] # what do we need here
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.
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.
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 """
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)
406def evaluateConditions(conditions, location):
407 """Return True if all the conditions matches the given location.
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
425def processRules(rules, location, glyphNames):
426 """Apply these rules at this location to these glyphnames.
428 Return a new list of glyphNames with substitutions applied.
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
450AnisotropicLocationDict = Dict[str, Union[float, Tuple[float, float]]]
451SimpleLocationDict = Dict[str, float]
454class AxisMappingDescriptor(SimpleDescriptor):
455 """Represents the axis mapping element: mapping an input location
456 to an output location in the designspace.
458 .. code:: python
460 m1 = AxisMappingDescriptor()
461 m1.inputLocation = {"weight": 900, "width": 150}
462 m1.outputLocation = {"weight": 870}
464 .. code:: xml
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 """
479 _attrs = ["inputLocation", "outputLocation"]
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.
485 varLib.
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.
492 varLib.
494 .. versionadded:: 5.1
495 """
498class InstanceDescriptor(SimpleDescriptor):
499 """Simple container for data related to the instance
502 .. code:: python
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 """
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 ]
542 filename = posixpath_property("_filename")
543 path = posixpath_property("_path")
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.
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.
581 MutatorMath.
582 """
583 self.font = font
584 """Same as :attr:`SourceDescriptor.font`
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.
598 .. seealso::
599 :meth:`getFullDesignLocation`
600 :meth:`getFullUserLocation`
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.
609 MutatorMath + varLib.
611 .. seealso:: This may be only part of the full location. See:
612 :meth:`getFullDesignLocation`
613 :meth:`getFullUserLocation`
615 .. versionadded:: 5.0
616 """
617 self.userLocation: SimpleLocationDict = userLocation or {}
618 """dict. Axis values for this instance, in user space coordinates.
620 MutatorMath + varLib.
622 .. seealso:: This may be only part of the full location. See:
623 :meth:`getFullDesignLocation`
624 :meth:`getFullUserLocation`
626 .. versionadded:: 5.0
627 """
628 self.familyName = familyName
629 """string. Family name of this instance.
631 MutatorMath + varLib.
632 """
633 self.styleName = styleName
634 """string. Style name of this instance.
636 MutatorMath + varLib.
637 """
638 self.postScriptFontName = postScriptFontName
639 """string. Postscript fontname for this instance.
641 MutatorMath + varLib.
642 """
643 self.styleMapFamilyName = styleMapFamilyName
644 """string. StyleMap familyname for this instance.
646 MutatorMath + varLib.
647 """
648 self.styleMapStyleName = styleMapStyleName
649 """string. StyleMap stylename for this instance.
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).
674 MutatorMath.
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.
683 MutatorMath.
685 .. deprecated:: 5.0
686 """
687 self.info = info
688 """bool. Indicated if this instance needs the interpolating
689 font.info calculated.
691 .. deprecated:: 5.0
692 """
694 self.lib = lib or {}
695 """Custom data associated with this instance."""
697 @property
698 def location(self):
699 """dict. Axis values for this instance.
701 MutatorMath + varLib.
703 .. deprecated:: 5.0
704 Use the more explicit alias for this property :attr:`designLocation`.
705 """
706 return self.designLocation
708 @location.setter
709 def location(self, location: Optional[AnisotropicLocationDict]):
710 self.designLocation = location or {}
712 def setStyleName(self, styleName, languageCode="en"):
713 """These methods give easier access to the localised names."""
714 self.localisedStyleName[languageCode] = tostr(styleName)
716 def getStyleName(self, languageCode="en"):
717 return self.localisedStyleName.get(languageCode)
719 def setFamilyName(self, familyName, languageCode="en"):
720 self.localisedFamilyName[languageCode] = tostr(familyName)
722 def getFamilyName(self, languageCode="en"):
723 return self.localisedFamilyName.get(languageCode)
725 def setStyleMapStyleName(self, styleMapStyleName, languageCode="en"):
726 self.localisedStyleMapStyleName[languageCode] = tostr(styleMapStyleName)
728 def getStyleMapStyleName(self, languageCode="en"):
729 return self.localisedStyleMapStyleName.get(languageCode)
731 def setStyleMapFamilyName(self, styleMapFamilyName, languageCode="en"):
732 self.localisedStyleMapFamilyName[languageCode] = tostr(styleMapFamilyName)
734 def getStyleMapFamilyName(self, languageCode="en"):
735 return self.localisedStyleMapFamilyName.get(languageCode)
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).
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.
746 .. code:: python
748 instance.clearLocation()
749 instance.designLocation = {'Weight': (34, 36.5), 'Width': 100}
750 instance.userLocation = {'Opsz': 16}
752 In order to update a single axis location, the user should only clear
753 that axis, then edit the values:
755 .. code:: python
757 instance.clearLocation('Weight')
758 instance.designLocation['Weight'] = (34, 36.5)
760 Args:
761 axisName: if provided, only clear the location for that axis.
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]
779 def getLocationLabelDescriptor(
780 self, doc: "DesignSpaceDocument"
781 ) -> Optional[LocationLabelDescriptor]:
782 """Get the :class:`LocationLabelDescriptor` instance that matches
783 this instances's :attr:`locationLabel`.
785 Raises if the named label can't be found.
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
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.
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:
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.
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
832 def getFullUserLocation(self, doc: "DesignSpaceDocument") -> SimpleLocationDict:
833 """Get the complete user location for this instance.
835 .. seealso:: :meth:`getFullDesignLocation`
837 .. versionadded:: 5.0
838 """
839 return doc.map_backward(self.getFullDesignLocation(doc))
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)
860class AbstractAxisDescriptor(SimpleDescriptor):
861 flavor = "axis"
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.
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)].
904 varLib.
905 """
906 self.axisOrdering = axisOrdering
907 """STAT table field ``axisOrdering``.
909 See: `OTSpec STAT Axis Record <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#axis-records>`_
911 .. versionadded:: 5.0
912 """
913 self.axisLabels: List[AxisLabelDescriptor] = axisLabels or []
914 """STAT table entries for Axis Value Tables format 1, 2, 3.
916 See: `OTSpec STAT Axis Value Tables <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#axis-value-tables>`_
918 .. versionadded:: 5.0
919 """
922class AxisDescriptor(AbstractAxisDescriptor):
923 """Simple container for the axis data.
925 Add more localisations?
927 .. code:: python
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 """
945 _attrs = [
946 "tag",
947 "name",
948 "maximum",
949 "minimum",
950 "default",
951 "map",
952 "axisOrdering",
953 "axisLabels",
954 ]
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.
982 MutatorMath + varLib.
983 """
984 self.maximum = maximum
985 """number. The maximum value for this axis in user space.
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.
993 MutatorMath + varLib.
994 """
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 )
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
1015 if not self.map:
1016 return v
1017 return piecewiseLinearMap(v, {k: v for k, v in self.map})
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
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})
1030class DiscreteAxisDescriptor(AbstractAxisDescriptor):
1031 """Container for discrete axis data.
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``.
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.
1042 .. code:: python
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)
1057 .. versionadded:: 5.0
1058 """
1060 flavor = "axis"
1061 _attrs = ("tag", "name", "values", "default", "map", "axisOrdering", "axisLabels")
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.
1089 However, this default value is less important than in continuous axes:
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 """
1101 def map_forward(self, value):
1102 """Maps value from axis mapping's input to output.
1104 Returns value unchanged if no mapping entry is found.
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)
1111 def map_backward(self, value):
1112 """Maps value from axis mapping's output to input.
1114 Returns value unchanged if no mapping entry is found.
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)
1124class AxisLabelDescriptor(SimpleDescriptor):
1125 """Container for axis label data.
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>`_
1131 The STAT format of the Axis value depends on which field are filled-in,
1132 see :meth:`getFormat`
1134 .. versionadded:: 5.0
1135 """
1137 flavor = "label"
1138 _attrs = (
1139 "userMinimum",
1140 "userValue",
1141 "userMaximum",
1142 "name",
1143 "elidable",
1144 "olderSibling",
1145 "linkedUserValue",
1146 "labelNames",
1147 )
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``.
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``.
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 """
1186 def getFormat(self) -> int:
1187 """Determine which format of STAT Axis value to use to encode this label.
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
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
1209class LocationLabelDescriptor(SimpleDescriptor):
1210 """Container for location label data.
1212 Analogue of OpenType's STAT data for a free-floating location (format 4).
1213 All values are user values.
1215 See: `OTSpec STAT Axis value table, format 4 <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#axis-value-table-format-4>`_
1217 .. versionadded:: 5.0
1218 """
1220 flavor = "label"
1221 _attrs = ("name", "elidable", "olderSibling", "userLocation", "labelNames")
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.
1237 If an axis is not mentioned, it is assumed to be at its default location.
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``.
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``.
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 """
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
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.
1266 .. versionadded:: 5.0
1267 """
1268 return {
1269 axis.name: self.userLocation.get(axis.name, axis.default)
1270 for axis in doc.axes
1271 }
1274class VariableFontDescriptor(SimpleDescriptor):
1275 """Container for variable fonts, sub-spaces of the Designspace.
1277 Use-cases:
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.
1285 .. versionadded:: 5.0
1286 """
1288 flavor = "variable-font"
1289 _attrs = ("filename", "axisSubsets", "lib")
1291 filename = posixpath_property("_filename")
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.
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.
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.
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."""
1319class RangeAxisSubsetDescriptor(SimpleDescriptor):
1320 """Subset of a continuous axis to include in a variable font.
1322 .. versionadded:: 5.0
1323 """
1325 flavor = "axis-subset"
1326 _attrs = ("name", "userMinimum", "userDefault", "userMaximum")
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 """
1350class ValueAxisSubsetDescriptor(SimpleDescriptor):
1351 """Single value of a discrete or continuous axis to use in a variable font.
1353 .. versionadded:: 5.0
1354 """
1356 flavor = "axis-subset"
1357 _attrs = ("name", "userValue")
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."""
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
1382 @classmethod
1383 def getAxisDecriptor(cls):
1384 return cls.axisDescriptorClass()
1386 @classmethod
1387 def getAxisMappingDescriptor(cls):
1388 return cls.axisMappingDescriptorClass()
1390 @classmethod
1391 def getSourceDescriptor(cls):
1392 return cls.sourceDescriptorClass()
1394 @classmethod
1395 def getInstanceDescriptor(cls):
1396 return cls.instanceDescriptorClass()
1398 @classmethod
1399 def getRuleDescriptor(cls):
1400 return cls.ruleDescriptorClass()
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")
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)
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)
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)
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)
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)
1446 if self.documentObject.sources:
1447 self.root.append(ET.Element("sources"))
1448 for sourceObject in self.documentObject.sources:
1449 self._addSource(sourceObject)
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)
1457 if self.documentObject.instances:
1458 self.root.append(ET.Element("instances"))
1459 for instanceObject in self.documentObject.instances:
1460 self._addInstance(instanceObject)
1462 if self.documentObject.lib:
1463 self._addLib(self.root, self.documentObject.lib, 2)
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 )
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
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
1522 def intOrFloat(self, num):
1523 if int(num) == num:
1524 return "%d" % num
1525 return ("%f" % num).rstrip("0").rstrip(".")
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)
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)
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)
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)
1604 mappingsElement.append(mappingElement)
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)
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)
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)
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)
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)
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)
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)
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)
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)
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
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
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
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
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()
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
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
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
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)
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 )
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 )
2159 def readLabels(self):
2160 if self.documentObject.formatTuple < (5, 0):
2161 return
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 )
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)
2200 def readVariableFonts(self):
2201 if self.documentObject.formatTuple < (5, 0):
2202 return
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 )
2212 name = variableFontElement.get("name")
2213 if name is None:
2214 raise DesignSpaceDocumentError(
2215 "variable-font element must have a name attribute."
2216 )
2218 filename = variableFontElement.get("filename")
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))
2229 lib = None
2230 libElement = variableFontElement.find(".lib")
2231 if libElement is not None:
2232 lib = plistlib.fromtree(libElement[0])
2234 variableFont = self.variableFontsDescriptorClass(
2235 name=name,
2236 filename=filename,
2237 axisSubsets=axisSubsets,
2238 lib=lib,
2239 )
2240 self.documentObject.variableFonts.append(variableFont)
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 )
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)
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 )
2272 name = element.get("name")
2273 if name is None:
2274 raise DesignSpaceDocumentError(
2275 "axis-subset element must have a name attribute."
2276 )
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)
2295 raise DesignSpaceDocumentError(
2296 "axis-subset element must have min/max/default values or none at all."
2297 )
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)
2363 def locationFromElement(self, element):
2364 """Read a nested ``<location>`` element inside the given ``element``.
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
2375 def readLocationElement(self, locationElement):
2376 """Read a ``<location>`` element.
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
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 )
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)
2507 def readLibElement(self, libElement, instanceObject):
2508 """Read the lib element for the given instance."""
2509 instanceObject.lib = plistlib.fromtree(libElement[0])
2511 def readInfoElement(self, infoElement, instanceObject):
2512 """Read the info element."""
2513 instanceObject.info = True
2515 def readGlyphElement(self, glyphElement, instanceObject):
2516 """
2517 Read the glyph element, which could look like either one of these:
2519 .. code-block:: xml
2521 <glyph name="b" unicode="0x62"/>
2523 <glyph name="b"/>
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 )
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
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])
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.
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.
2601 **Note:** Python attribute names are usually camelCased, the
2602 corresponding `XML <document-xml-structure>`_ attributes are usually
2603 all lowercase.
2605 .. code:: python
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
2621 """
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.
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 """
2637 self.formatVersion: Optional[str] = None
2638 """Format version for this document, as a string. E.g. "4.0" """
2640 self.elidedFallbackName: Optional[str] = None
2641 """STAT Style Attributes Header field ``elidedFallbackNameID``.
2643 See: `OTSpec STAT Style Attributes Header <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#style-attributes-header>`_
2645 .. versionadded:: 5.0
2646 """
2648 self.axes: List[Union[AxisDescriptor, DiscreteAxisDescriptor]] = []
2649 """List of this document's axes."""
2651 self.axisMappings: List[AxisMappingDescriptor] = []
2652 """List of this document's axis mappings."""
2654 self.locationLabels: List[LocationLabelDescriptor] = []
2655 """List of this document's STAT format 4 labels.
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.
2664 - False: before
2665 - True: after.
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>`__
2672 If you want to use a different feature altogether, e.g. ``calt``,
2673 use the lib key ``com.github.fonttools.varLib.featureVarsFeatureTag``
2675 .. code:: xml
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.
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.
2695 Use reverse-DNS notation to identify your own data.
2696 Respect the data stored by others.
2697 """
2699 self.default: Optional[str] = None
2700 """Name of the default master.
2702 This attribute is updated by the :meth:`findDefault`
2703 """
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
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
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
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()
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()
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()
2770 def _posixRelativePath(self, otherPath):
2771 relative = os.path.relpath(otherPath, os.path.dirname(self.path))
2772 return posix(relative)
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.
2779 ::
2781 case 1.
2782 descriptor.filename == None
2783 descriptor.path == None
2785 -- action:
2786 write as is, descriptors will not have a filename attr.
2787 useless, but no reason to interfere.
2790 case 2.
2791 descriptor.filename == "../something"
2792 descriptor.path == None
2794 -- action:
2795 write as is. The filename attr should not be touched.
2798 case 3.
2799 descriptor.filename == None
2800 descriptor.path == "~/absolute/path/there"
2802 -- action:
2803 calculate the relative path for filename.
2804 We're not overwriting some other value for filename, it should be fine
2807 case 4.
2808 descriptor.filename == '../somewhere'
2809 descriptor.path == "~/absolute/path/there"
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)
2822 def addSource(self, sourceDescriptor: SourceDescriptor):
2823 """Add the given ``sourceDescriptor`` to ``doc.sources``."""
2824 self.sources.append(sourceDescriptor)
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
2834 def addInstance(self, instanceDescriptor: InstanceDescriptor):
2835 """Add the given ``instanceDescriptor`` to :attr:`instances`."""
2836 self.instances.append(instanceDescriptor)
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
2846 def addAxis(self, axisDescriptor: Union[AxisDescriptor, DiscreteAxisDescriptor]):
2847 """Add the given ``axisDescriptor`` to :attr:`axes`."""
2848 self.axes.append(axisDescriptor)
2850 def addAxisDescriptor(self, **kwargs):
2851 """Instantiate a new :class:`AxisDescriptor` using the given
2852 ``kwargs`` and add it to :attr:`axes`.
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
2864 def addAxisMapping(self, axisMappingDescriptor: AxisMappingDescriptor):
2865 """Add the given ``axisMappingDescriptor`` to :attr:`axisMappings`."""
2866 self.axisMappings.append(axisMappingDescriptor)
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
2876 def addRule(self, ruleDescriptor: RuleDescriptor):
2877 """Add the given ``ruleDescriptor`` to :attr:`rules`."""
2878 self.rules.append(ruleDescriptor)
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
2888 def addVariableFont(self, variableFontDescriptor: VariableFontDescriptor):
2889 """Add the given ``variableFontDescriptor`` to :attr:`variableFonts`.
2891 .. versionadded:: 5.0
2892 """
2893 self.variableFonts.append(variableFontDescriptor)
2895 def addVariableFontDescriptor(self, **kwargs):
2896 """Instantiate a new :class:`VariableFontDescriptor` using the given
2897 ``kwargs`` and add it to :attr:`variableFonts`.
2899 .. versionadded:: 5.0
2900 """
2901 variableFont = self.writerClass.variableFontDescriptorClass(**kwargs)
2902 self.addVariableFont(variableFont)
2903 return variableFont
2905 def addLocationLabel(self, locationLabelDescriptor: LocationLabelDescriptor):
2906 """Add the given ``locationLabelDescriptor`` to :attr:`locationLabels`.
2908 .. versionadded:: 5.0
2909 """
2910 self.locationLabels.append(locationLabelDescriptor)
2912 def addLocationLabelDescriptor(self, **kwargs):
2913 """Instantiate a new :class:`LocationLabelDescriptor` using the given
2914 ``kwargs`` and add it to :attr:`locationLabels`.
2916 .. versionadded:: 5.0
2917 """
2918 locationLabel = self.writerClass.locationLabelDescriptorClass(**kwargs)
2919 self.addLocationLabel(locationLabel)
2920 return locationLabel
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
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.
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 )
2950 def updateFilenameFromPath(self, masters=True, instances=True, force=False):
2951 """Set a descriptor filename attr from the path and this document path.
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)
2968 def newAxisDescriptor(self):
2969 """Ask the writer class to make us a new axisDescriptor."""
2970 return self.writerClass.getAxisDecriptor()
2972 def newSourceDescriptor(self):
2973 """Ask the writer class to make us a new sourceDescriptor."""
2974 return self.writerClass.getSourceDescriptor()
2976 def newInstanceDescriptor(self):
2977 """Ask the writer class to make us a new instanceDescriptor."""
2978 return self.writerClass.getInstanceDescriptor()
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
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)
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)
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.
2999 .. versionadded:: 5.0
3000 """
3001 for label in self.locationLabels:
3002 if label.name == name:
3003 return label
3004 return None
3006 def map_forward(self, userLocation: SimpleLocationDict) -> SimpleLocationDict:
3007 """Map a user location to a design location.
3009 Assume that missing coordinates are at the default location for that axis.
3011 Note: the output won't be anisotropic, only the xvalue is set.
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 }
3020 def map_backward(
3021 self, designLocation: AnisotropicLocationDict
3022 ) -> SimpleLocationDict:
3023 """Map a design location to a user location.
3025 Assume that missing coordinates are at the default location for that axis.
3027 When the input has anisotropic locations, only the xvalue is used.
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 }
3040 def findDefault(self):
3041 """Set and return SourceDescriptor at the default location or None.
3043 The default location is the set of all `default` values in user space
3044 of all axes.
3046 This function updates the document's :attr:`default` value.
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
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()
3059 for sourceDescriptor in self.sources:
3060 if sourceDescriptor.getFullDesignLocation(self) == defaultDesignLocation:
3061 self.default = sourceDescriptor
3062 return sourceDescriptor
3064 return None
3066 def normalizeLocation(self, location):
3067 """Return a dict with normalized axis values."""
3068 from fontTools.varLib.models import normalizeValue
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
3085 def normalize(self):
3086 """
3087 Normalise the geometry of this designspace:
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
3150 def loadSourceFonts(self, opener, **kwargs):
3151 """Ensure SourceDescriptor.font attributes are loaded, and return list of fonts.
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.
3159 For example, to load UFO sources using defcon:
3161 designspace = DesignSpaceDocument.fromfile("path/to/my.designspace")
3162 designspace.loadSourceFonts(defcon.Font)
3164 Or to load masters as FontTools binary fonts, including extra options:
3166 designspace.loadSourceFonts(ttLib.TTFont, recalcBBoxes=False)
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.
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
3197 @property
3198 def formatTuple(self):
3199 """Return the formatVersion as a tuple of (major, minor).
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)
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.
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.
3218 In version 5 and above documents, there can be as many variable fonts
3219 as there are locations on discrete axes.
3221 .. seealso:: :func:`splitInterpolable`
3223 .. versionadded:: 5.0
3224 """
3225 if self.variableFonts:
3226 return self.variableFonts
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
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.
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