Spaces:
Sleeping
Sleeping
""" | |
designSpaceDocument | |
- Read and write designspace files | |
""" | |
from __future__ import annotations | |
import collections | |
import copy | |
import itertools | |
import math | |
import os | |
import posixpath | |
from io import BytesIO, StringIO | |
from textwrap import indent | |
from typing import Any, Dict, List, MutableMapping, Optional, Tuple, Union, cast | |
from fontTools.misc import etree as ET | |
from fontTools.misc import plistlib | |
from fontTools.misc.loggingTools import LogMixin | |
from fontTools.misc.textTools import tobytes, tostr | |
__all__ = [ | |
"AxisDescriptor", | |
"AxisLabelDescriptor", | |
"AxisMappingDescriptor", | |
"BaseDocReader", | |
"BaseDocWriter", | |
"DesignSpaceDocument", | |
"DesignSpaceDocumentError", | |
"DiscreteAxisDescriptor", | |
"InstanceDescriptor", | |
"LocationLabelDescriptor", | |
"RangeAxisSubsetDescriptor", | |
"RuleDescriptor", | |
"SourceDescriptor", | |
"ValueAxisSubsetDescriptor", | |
"VariableFontDescriptor", | |
] | |
# ElementTree allows to find namespace-prefixed elements, but not attributes | |
# so we have to do it ourselves for 'xml:lang' | |
XML_NS = "{http://www.w3.org/XML/1998/namespace}" | |
XML_LANG = XML_NS + "lang" | |
def posix(path): | |
"""Normalize paths using forward slash to work also on Windows.""" | |
new_path = posixpath.join(*path.split(os.path.sep)) | |
if path.startswith("/"): | |
# The above transformation loses absolute paths | |
new_path = "/" + new_path | |
elif path.startswith(r"\\"): | |
# The above transformation loses leading slashes of UNC path mounts | |
new_path = "//" + new_path | |
return new_path | |
def posixpath_property(private_name): | |
"""Generate a propery that holds a path always using forward slashes.""" | |
def getter(self): | |
# Normal getter | |
return getattr(self, private_name) | |
def setter(self, value): | |
# The setter rewrites paths using forward slashes | |
if value is not None: | |
value = posix(value) | |
setattr(self, private_name, value) | |
return property(getter, setter) | |
class DesignSpaceDocumentError(Exception): | |
def __init__(self, msg, obj=None): | |
self.msg = msg | |
self.obj = obj | |
def __str__(self): | |
return str(self.msg) + (": %r" % self.obj if self.obj is not None else "") | |
class AsDictMixin(object): | |
def asdict(self): | |
d = {} | |
for attr, value in self.__dict__.items(): | |
if attr.startswith("_"): | |
continue | |
if hasattr(value, "asdict"): | |
value = value.asdict() | |
elif isinstance(value, list): | |
value = [v.asdict() if hasattr(v, "asdict") else v for v in value] | |
d[attr] = value | |
return d | |
class SimpleDescriptor(AsDictMixin): | |
"""Containers for a bunch of attributes""" | |
# XXX this is ugly. The 'print' is inappropriate here, and instead of | |
# assert, it should simply return True/False | |
def compare(self, other): | |
# test if this object contains the same data as the other | |
for attr in self._attrs: | |
try: | |
assert getattr(self, attr) == getattr(other, attr) | |
except AssertionError: | |
print( | |
"failed attribute", | |
attr, | |
getattr(self, attr), | |
"!=", | |
getattr(other, attr), | |
) | |
def __repr__(self): | |
attrs = [f"{a}={repr(getattr(self, a))}," for a in self._attrs] | |
attrs = indent("\n".join(attrs), " ") | |
return f"{self.__class__.__name__}(\n{attrs}\n)" | |
class SourceDescriptor(SimpleDescriptor): | |
"""Simple container for data related to the source | |
.. code:: python | |
doc = DesignSpaceDocument() | |
s1 = SourceDescriptor() | |
s1.path = masterPath1 | |
s1.name = "master.ufo1" | |
s1.font = defcon.Font("master.ufo1") | |
s1.location = dict(weight=0) | |
s1.familyName = "MasterFamilyName" | |
s1.styleName = "MasterStyleNameOne" | |
s1.localisedFamilyName = dict(fr="Caractère") | |
s1.mutedGlyphNames.append("A") | |
s1.mutedGlyphNames.append("Z") | |
doc.addSource(s1) | |
""" | |
flavor = "source" | |
_attrs = [ | |
"filename", | |
"path", | |
"name", | |
"layerName", | |
"location", | |
"copyLib", | |
"copyGroups", | |
"copyFeatures", | |
"muteKerning", | |
"muteInfo", | |
"mutedGlyphNames", | |
"familyName", | |
"styleName", | |
"localisedFamilyName", | |
] | |
filename = posixpath_property("_filename") | |
path = posixpath_property("_path") | |
def __init__( | |
self, | |
*, | |
filename=None, | |
path=None, | |
font=None, | |
name=None, | |
location=None, | |
designLocation=None, | |
layerName=None, | |
familyName=None, | |
styleName=None, | |
localisedFamilyName=None, | |
copyLib=False, | |
copyInfo=False, | |
copyGroups=False, | |
copyFeatures=False, | |
muteKerning=False, | |
muteInfo=False, | |
mutedGlyphNames=None, | |
): | |
self.filename = filename | |
"""string. A relative path to the source file, **as it is in the document**. | |
MutatorMath + VarLib. | |
""" | |
self.path = path | |
"""The absolute path, calculated from filename.""" | |
self.font = font | |
"""Any Python object. Optional. Points to a representation of this | |
source font that is loaded in memory, as a Python object (e.g. a | |
``defcon.Font`` or a ``fontTools.ttFont.TTFont``). | |
The default document reader will not fill-in this attribute, and the | |
default writer will not use this attribute. It is up to the user of | |
``designspaceLib`` to either load the resource identified by | |
``filename`` and store it in this field, or write the contents of | |
this field to the disk and make ```filename`` point to that. | |
""" | |
self.name = name | |
"""string. Optional. Unique identifier name for this source. | |
MutatorMath + varLib. | |
""" | |
self.designLocation = ( | |
designLocation if designLocation is not None else location or {} | |
) | |
"""dict. Axis values for this source, in design space coordinates. | |
MutatorMath + varLib. | |
This may be only part of the full design location. | |
See :meth:`getFullDesignLocation()` | |
.. versionadded:: 5.0 | |
""" | |
self.layerName = layerName | |
"""string. The name of the layer in the source to look for | |
outline data. Default ``None`` which means ``foreground``. | |
""" | |
self.familyName = familyName | |
"""string. Family name of this source. Though this data | |
can be extracted from the font, it can be efficient to have it right | |
here. | |
varLib. | |
""" | |
self.styleName = styleName | |
"""string. Style name of this source. Though this data | |
can be extracted from the font, it can be efficient to have it right | |
here. | |
varLib. | |
""" | |
self.localisedFamilyName = localisedFamilyName or {} | |
"""dict. A dictionary of localised family name strings, keyed by | |
language code. | |
If present, will be used to build localized names for all instances. | |
.. versionadded:: 5.0 | |
""" | |
self.copyLib = copyLib | |
"""bool. Indicates if the contents of the font.lib need to | |
be copied to the instances. | |
MutatorMath. | |
.. deprecated:: 5.0 | |
""" | |
self.copyInfo = copyInfo | |
"""bool. Indicates if the non-interpolating font.info needs | |
to be copied to the instances. | |
MutatorMath. | |
.. deprecated:: 5.0 | |
""" | |
self.copyGroups = copyGroups | |
"""bool. Indicates if the groups need to be copied to the | |
instances. | |
MutatorMath. | |
.. deprecated:: 5.0 | |
""" | |
self.copyFeatures = copyFeatures | |
"""bool. Indicates if the feature text needs to be | |
copied to the instances. | |
MutatorMath. | |
.. deprecated:: 5.0 | |
""" | |
self.muteKerning = muteKerning | |
"""bool. Indicates if the kerning data from this source | |
needs to be muted (i.e. not be part of the calculations). | |
MutatorMath only. | |
""" | |
self.muteInfo = muteInfo | |
"""bool. Indicated if the interpolating font.info data for | |
this source needs to be muted. | |
MutatorMath only. | |
""" | |
self.mutedGlyphNames = mutedGlyphNames or [] | |
"""list. Glyphnames that need to be muted in the | |
instances. | |
MutatorMath only. | |
""" | |
def location(self): | |
"""dict. Axis values for this source, in design space coordinates. | |
MutatorMath + varLib. | |
.. deprecated:: 5.0 | |
Use the more explicit alias for this property :attr:`designLocation`. | |
""" | |
return self.designLocation | |
def location(self, location: Optional[SimpleLocationDict]): | |
self.designLocation = location or {} | |
def setFamilyName(self, familyName, languageCode="en"): | |
"""Setter for :attr:`localisedFamilyName` | |
.. versionadded:: 5.0 | |
""" | |
self.localisedFamilyName[languageCode] = tostr(familyName) | |
def getFamilyName(self, languageCode="en"): | |
"""Getter for :attr:`localisedFamilyName` | |
.. versionadded:: 5.0 | |
""" | |
return self.localisedFamilyName.get(languageCode) | |
def getFullDesignLocation(self, doc: "DesignSpaceDocument") -> SimpleLocationDict: | |
"""Get the complete design location of this source, from its | |
:attr:`designLocation` and the document's axis defaults. | |
.. versionadded:: 5.0 | |
""" | |
result: SimpleLocationDict = {} | |
for axis in doc.axes: | |
if axis.name in self.designLocation: | |
result[axis.name] = self.designLocation[axis.name] | |
else: | |
result[axis.name] = axis.map_forward(axis.default) | |
return result | |
class RuleDescriptor(SimpleDescriptor): | |
"""Represents the rule descriptor element: a set of glyph substitutions to | |
trigger conditionally in some parts of the designspace. | |
.. code:: python | |
r1 = RuleDescriptor() | |
r1.name = "unique.rule.name" | |
r1.conditionSets.append([dict(name="weight", minimum=-10, maximum=10), dict(...)]) | |
r1.conditionSets.append([dict(...), dict(...)]) | |
r1.subs.append(("a", "a.alt")) | |
.. code:: xml | |
<!-- optional: list of substitution rules --> | |
<rules> | |
<rule name="vertical.bars"> | |
<conditionset> | |
<condition minimum="250.000000" maximum="750.000000" name="weight"/> | |
<condition minimum="100" name="width"/> | |
<condition minimum="10" maximum="40" name="optical"/> | |
</conditionset> | |
<sub name="cent" with="cent.alt"/> | |
<sub name="dollar" with="dollar.alt"/> | |
</rule> | |
</rules> | |
""" | |
_attrs = ["name", "conditionSets", "subs"] # what do we need here | |
def __init__(self, *, name=None, conditionSets=None, subs=None): | |
self.name = name | |
"""string. Unique name for this rule. Can be used to reference this rule data.""" | |
# list of lists of dict(name='aaaa', minimum=0, maximum=1000) | |
self.conditionSets = conditionSets or [] | |
"""a list of conditionsets. | |
- Each conditionset is a list of conditions. | |
- Each condition is a dict with ``name``, ``minimum`` and ``maximum`` keys. | |
""" | |
# list of substitutions stored as tuples of glyphnames ("a", "a.alt") | |
self.subs = subs or [] | |
"""list of substitutions. | |
- Each substitution is stored as tuples of glyphnames, e.g. ("a", "a.alt"). | |
- Note: By default, rules are applied first, before other text | |
shaping/OpenType layout, as they are part of the | |
`Required Variation Alternates OpenType feature <https://docs.microsoft.com/en-us/typography/opentype/spec/features_pt#-tag-rvrn>`_. | |
See ref:`rules-element` § Attributes. | |
""" | |
def evaluateRule(rule, location): | |
"""Return True if any of the rule's conditionsets matches the given location.""" | |
return any(evaluateConditions(c, location) for c in rule.conditionSets) | |
def evaluateConditions(conditions, location): | |
"""Return True if all the conditions matches the given location. | |
- If a condition has no minimum, check for < maximum. | |
- If a condition has no maximum, check for > minimum. | |
""" | |
for cd in conditions: | |
value = location[cd["name"]] | |
if cd.get("minimum") is None: | |
if value > cd["maximum"]: | |
return False | |
elif cd.get("maximum") is None: | |
if cd["minimum"] > value: | |
return False | |
elif not cd["minimum"] <= value <= cd["maximum"]: | |
return False | |
return True | |
def processRules(rules, location, glyphNames): | |
"""Apply these rules at this location to these glyphnames. | |
Return a new list of glyphNames with substitutions applied. | |
- rule order matters | |
""" | |
newNames = [] | |
for rule in rules: | |
if evaluateRule(rule, location): | |
for name in glyphNames: | |
swap = False | |
for a, b in rule.subs: | |
if name == a: | |
swap = True | |
break | |
if swap: | |
newNames.append(b) | |
else: | |
newNames.append(name) | |
glyphNames = newNames | |
newNames = [] | |
return glyphNames | |
AnisotropicLocationDict = Dict[str, Union[float, Tuple[float, float]]] | |
SimpleLocationDict = Dict[str, float] | |
class AxisMappingDescriptor(SimpleDescriptor): | |
"""Represents the axis mapping element: mapping an input location | |
to an output location in the designspace. | |
.. code:: python | |
m1 = AxisMappingDescriptor() | |
m1.inputLocation = {"weight": 900, "width": 150} | |
m1.outputLocation = {"weight": 870} | |
.. code:: xml | |
<mappings> | |
<mapping> | |
<input> | |
<dimension name="weight" xvalue="900"/> | |
<dimension name="width" xvalue="150"/> | |
</input> | |
<output> | |
<dimension name="weight" xvalue="870"/> | |
</output> | |
</mapping> | |
</mappings> | |
""" | |
_attrs = ["inputLocation", "outputLocation"] | |
def __init__( | |
self, | |
*, | |
inputLocation=None, | |
outputLocation=None, | |
description=None, | |
groupDescription=None, | |
): | |
self.inputLocation: SimpleLocationDict = inputLocation or {} | |
"""dict. Axis values for the input of the mapping, in design space coordinates. | |
varLib. | |
.. versionadded:: 5.1 | |
""" | |
self.outputLocation: SimpleLocationDict = outputLocation or {} | |
"""dict. Axis values for the output of the mapping, in design space coordinates. | |
varLib. | |
.. versionadded:: 5.1 | |
""" | |
self.description = description | |
"""string. A description of the mapping. | |
varLib. | |
.. versionadded:: 5.2 | |
""" | |
self.groupDescription = groupDescription | |
"""string. A description of the group of mappings. | |
varLib. | |
.. versionadded:: 5.2 | |
""" | |
class InstanceDescriptor(SimpleDescriptor): | |
"""Simple container for data related to the instance | |
.. code:: python | |
i2 = InstanceDescriptor() | |
i2.path = instancePath2 | |
i2.familyName = "InstanceFamilyName" | |
i2.styleName = "InstanceStyleName" | |
i2.name = "instance.ufo2" | |
# anisotropic location | |
i2.designLocation = dict(weight=500, width=(400,300)) | |
i2.postScriptFontName = "InstancePostscriptName" | |
i2.styleMapFamilyName = "InstanceStyleMapFamilyName" | |
i2.styleMapStyleName = "InstanceStyleMapStyleName" | |
i2.lib['com.coolDesignspaceApp.specimenText'] = 'Hamburgerwhatever' | |
doc.addInstance(i2) | |
""" | |
flavor = "instance" | |
_defaultLanguageCode = "en" | |
_attrs = [ | |
"filename", | |
"path", | |
"name", | |
"locationLabel", | |
"designLocation", | |
"userLocation", | |
"familyName", | |
"styleName", | |
"postScriptFontName", | |
"styleMapFamilyName", | |
"styleMapStyleName", | |
"localisedFamilyName", | |
"localisedStyleName", | |
"localisedStyleMapFamilyName", | |
"localisedStyleMapStyleName", | |
"glyphs", | |
"kerning", | |
"info", | |
"lib", | |
] | |
filename = posixpath_property("_filename") | |
path = posixpath_property("_path") | |
def __init__( | |
self, | |
*, | |
filename=None, | |
path=None, | |
font=None, | |
name=None, | |
location=None, | |
locationLabel=None, | |
designLocation=None, | |
userLocation=None, | |
familyName=None, | |
styleName=None, | |
postScriptFontName=None, | |
styleMapFamilyName=None, | |
styleMapStyleName=None, | |
localisedFamilyName=None, | |
localisedStyleName=None, | |
localisedStyleMapFamilyName=None, | |
localisedStyleMapStyleName=None, | |
glyphs=None, | |
kerning=True, | |
info=True, | |
lib=None, | |
): | |
self.filename = filename | |
"""string. Relative path to the instance file, **as it is | |
in the document**. The file may or may not exist. | |
MutatorMath + VarLib. | |
""" | |
self.path = path | |
"""string. Absolute path to the instance file, calculated from | |
the document path and the string in the filename attr. The file may | |
or may not exist. | |
MutatorMath. | |
""" | |
self.font = font | |
"""Same as :attr:`SourceDescriptor.font` | |
.. seealso:: :attr:`SourceDescriptor.font` | |
""" | |
self.name = name | |
"""string. Unique identifier name of the instance, used to | |
identify it if it needs to be referenced from elsewhere in the | |
document. | |
""" | |
self.locationLabel = locationLabel | |
"""Name of a :class:`LocationLabelDescriptor`. If | |
provided, the instance should have the same location as the | |
LocationLabel. | |
.. seealso:: | |
:meth:`getFullDesignLocation` | |
:meth:`getFullUserLocation` | |
.. versionadded:: 5.0 | |
""" | |
self.designLocation: AnisotropicLocationDict = ( | |
designLocation if designLocation is not None else (location or {}) | |
) | |
"""dict. Axis values for this instance, in design space coordinates. | |
MutatorMath + varLib. | |
.. seealso:: This may be only part of the full location. See: | |
:meth:`getFullDesignLocation` | |
:meth:`getFullUserLocation` | |
.. versionadded:: 5.0 | |
""" | |
self.userLocation: SimpleLocationDict = userLocation or {} | |
"""dict. Axis values for this instance, in user space coordinates. | |
MutatorMath + varLib. | |
.. seealso:: This may be only part of the full location. See: | |
:meth:`getFullDesignLocation` | |
:meth:`getFullUserLocation` | |
.. versionadded:: 5.0 | |
""" | |
self.familyName = familyName | |
"""string. Family name of this instance. | |
MutatorMath + varLib. | |
""" | |
self.styleName = styleName | |
"""string. Style name of this instance. | |
MutatorMath + varLib. | |
""" | |
self.postScriptFontName = postScriptFontName | |
"""string. Postscript fontname for this instance. | |
MutatorMath + varLib. | |
""" | |
self.styleMapFamilyName = styleMapFamilyName | |
"""string. StyleMap familyname for this instance. | |
MutatorMath + varLib. | |
""" | |
self.styleMapStyleName = styleMapStyleName | |
"""string. StyleMap stylename for this instance. | |
MutatorMath + varLib. | |
""" | |
self.localisedFamilyName = localisedFamilyName or {} | |
"""dict. A dictionary of localised family name | |
strings, keyed by language code. | |
""" | |
self.localisedStyleName = localisedStyleName or {} | |
"""dict. A dictionary of localised stylename | |
strings, keyed by language code. | |
""" | |
self.localisedStyleMapFamilyName = localisedStyleMapFamilyName or {} | |
"""A dictionary of localised style map | |
familyname strings, keyed by language code. | |
""" | |
self.localisedStyleMapStyleName = localisedStyleMapStyleName or {} | |
"""A dictionary of localised style map | |
stylename strings, keyed by language code. | |
""" | |
self.glyphs = glyphs or {} | |
"""dict for special master definitions for glyphs. If glyphs | |
need special masters (to record the results of executed rules for | |
example). | |
MutatorMath. | |
.. deprecated:: 5.0 | |
Use rules or sparse sources instead. | |
""" | |
self.kerning = kerning | |
""" bool. Indicates if this instance needs its kerning | |
calculated. | |
MutatorMath. | |
.. deprecated:: 5.0 | |
""" | |
self.info = info | |
"""bool. Indicated if this instance needs the interpolating | |
font.info calculated. | |
.. deprecated:: 5.0 | |
""" | |
self.lib = lib or {} | |
"""Custom data associated with this instance.""" | |
def location(self): | |
"""dict. Axis values for this instance. | |
MutatorMath + varLib. | |
.. deprecated:: 5.0 | |
Use the more explicit alias for this property :attr:`designLocation`. | |
""" | |
return self.designLocation | |
def location(self, location: Optional[AnisotropicLocationDict]): | |
self.designLocation = location or {} | |
def setStyleName(self, styleName, languageCode="en"): | |
"""These methods give easier access to the localised names.""" | |
self.localisedStyleName[languageCode] = tostr(styleName) | |
def getStyleName(self, languageCode="en"): | |
return self.localisedStyleName.get(languageCode) | |
def setFamilyName(self, familyName, languageCode="en"): | |
self.localisedFamilyName[languageCode] = tostr(familyName) | |
def getFamilyName(self, languageCode="en"): | |
return self.localisedFamilyName.get(languageCode) | |
def setStyleMapStyleName(self, styleMapStyleName, languageCode="en"): | |
self.localisedStyleMapStyleName[languageCode] = tostr(styleMapStyleName) | |
def getStyleMapStyleName(self, languageCode="en"): | |
return self.localisedStyleMapStyleName.get(languageCode) | |
def setStyleMapFamilyName(self, styleMapFamilyName, languageCode="en"): | |
self.localisedStyleMapFamilyName[languageCode] = tostr(styleMapFamilyName) | |
def getStyleMapFamilyName(self, languageCode="en"): | |
return self.localisedStyleMapFamilyName.get(languageCode) | |
def clearLocation(self, axisName: Optional[str] = None): | |
"""Clear all location-related fields. Ensures that | |
:attr:``designLocation`` and :attr:``userLocation`` are dictionaries | |
(possibly empty if clearing everything). | |
In order to update the location of this instance wholesale, a user | |
should first clear all the fields, then change the field(s) for which | |
they have data. | |
.. code:: python | |
instance.clearLocation() | |
instance.designLocation = {'Weight': (34, 36.5), 'Width': 100} | |
instance.userLocation = {'Opsz': 16} | |
In order to update a single axis location, the user should only clear | |
that axis, then edit the values: | |
.. code:: python | |
instance.clearLocation('Weight') | |
instance.designLocation['Weight'] = (34, 36.5) | |
Args: | |
axisName: if provided, only clear the location for that axis. | |
.. versionadded:: 5.0 | |
""" | |
self.locationLabel = None | |
if axisName is None: | |
self.designLocation = {} | |
self.userLocation = {} | |
else: | |
if self.designLocation is None: | |
self.designLocation = {} | |
if axisName in self.designLocation: | |
del self.designLocation[axisName] | |
if self.userLocation is None: | |
self.userLocation = {} | |
if axisName in self.userLocation: | |
del self.userLocation[axisName] | |
def getLocationLabelDescriptor( | |
self, doc: "DesignSpaceDocument" | |
) -> Optional[LocationLabelDescriptor]: | |
"""Get the :class:`LocationLabelDescriptor` instance that matches | |
this instances's :attr:`locationLabel`. | |
Raises if the named label can't be found. | |
.. versionadded:: 5.0 | |
""" | |
if self.locationLabel is None: | |
return None | |
label = doc.getLocationLabel(self.locationLabel) | |
if label is None: | |
raise DesignSpaceDocumentError( | |
"InstanceDescriptor.getLocationLabelDescriptor(): " | |
f"unknown location label `{self.locationLabel}` in instance `{self.name}`." | |
) | |
return label | |
def getFullDesignLocation( | |
self, doc: "DesignSpaceDocument" | |
) -> AnisotropicLocationDict: | |
"""Get the complete design location of this instance, by combining data | |
from the various location fields, default axis values and mappings, and | |
top-level location labels. | |
The source of truth for this instance's location is determined for each | |
axis independently by taking the first not-None field in this list: | |
- ``locationLabel``: the location along this axis is the same as the | |
matching STAT format 4 label. No anisotropy. | |
- ``designLocation[axisName]``: the explicit design location along this | |
axis, possibly anisotropic. | |
- ``userLocation[axisName]``: the explicit user location along this | |
axis. No anisotropy. | |
- ``axis.default``: default axis value. No anisotropy. | |
.. versionadded:: 5.0 | |
""" | |
label = self.getLocationLabelDescriptor(doc) | |
if label is not None: | |
return doc.map_forward(label.userLocation) # type: ignore | |
result: AnisotropicLocationDict = {} | |
for axis in doc.axes: | |
if axis.name in self.designLocation: | |
result[axis.name] = self.designLocation[axis.name] | |
elif axis.name in self.userLocation: | |
result[axis.name] = axis.map_forward(self.userLocation[axis.name]) | |
else: | |
result[axis.name] = axis.map_forward(axis.default) | |
return result | |
def getFullUserLocation(self, doc: "DesignSpaceDocument") -> SimpleLocationDict: | |
"""Get the complete user location for this instance. | |
.. seealso:: :meth:`getFullDesignLocation` | |
.. versionadded:: 5.0 | |
""" | |
return doc.map_backward(self.getFullDesignLocation(doc)) | |
def tagForAxisName(name): | |
# try to find or make a tag name for this axis name | |
names = { | |
"weight": ("wght", dict(en="Weight")), | |
"width": ("wdth", dict(en="Width")), | |
"optical": ("opsz", dict(en="Optical Size")), | |
"slant": ("slnt", dict(en="Slant")), | |
"italic": ("ital", dict(en="Italic")), | |
} | |
if name.lower() in names: | |
return names[name.lower()] | |
if len(name) < 4: | |
tag = name + "*" * (4 - len(name)) | |
else: | |
tag = name[:4] | |
return tag, dict(en=name) | |
class AbstractAxisDescriptor(SimpleDescriptor): | |
flavor = "axis" | |
def __init__( | |
self, | |
*, | |
tag=None, | |
name=None, | |
labelNames=None, | |
hidden=False, | |
map=None, | |
axisOrdering=None, | |
axisLabels=None, | |
): | |
# opentype tag for this axis | |
self.tag = tag | |
"""string. Four letter tag for this axis. Some might be | |
registered at the `OpenType | |
specification <https://www.microsoft.com/typography/otspec/fvar.htm#VAT>`__. | |
Privately-defined axis tags must begin with an uppercase letter and | |
use only uppercase letters or digits. | |
""" | |
# name of the axis used in locations | |
self.name = name | |
"""string. Name of the axis as it is used in the location dicts. | |
MutatorMath + varLib. | |
""" | |
# names for UI purposes, if this is not a standard axis, | |
self.labelNames = labelNames or {} | |
"""dict. When defining a non-registered axis, it will be | |
necessary to define user-facing readable names for the axis. Keyed by | |
xml:lang code. Values are required to be ``unicode`` strings, even if | |
they only contain ASCII characters. | |
""" | |
self.hidden = hidden | |
"""bool. Whether this axis should be hidden in user interfaces. | |
""" | |
self.map = map or [] | |
"""list of input / output values that can describe a warp of user space | |
to design space coordinates. If no map values are present, it is assumed | |
user space is the same as design space, as in [(minimum, minimum), | |
(maximum, maximum)]. | |
varLib. | |
""" | |
self.axisOrdering = axisOrdering | |
"""STAT table field ``axisOrdering``. | |
See: `OTSpec STAT Axis Record <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#axis-records>`_ | |
.. versionadded:: 5.0 | |
""" | |
self.axisLabels: List[AxisLabelDescriptor] = axisLabels or [] | |
"""STAT table entries for Axis Value Tables format 1, 2, 3. | |
See: `OTSpec STAT Axis Value Tables <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#axis-value-tables>`_ | |
.. versionadded:: 5.0 | |
""" | |
class AxisDescriptor(AbstractAxisDescriptor): | |
"""Simple container for the axis data. | |
Add more localisations? | |
.. code:: python | |
a1 = AxisDescriptor() | |
a1.minimum = 1 | |
a1.maximum = 1000 | |
a1.default = 400 | |
a1.name = "weight" | |
a1.tag = "wght" | |
a1.labelNames['fa-IR'] = "قطر" | |
a1.labelNames['en'] = "Wéíght" | |
a1.map = [(1.0, 10.0), (400.0, 66.0), (1000.0, 990.0)] | |
a1.axisOrdering = 1 | |
a1.axisLabels = [ | |
AxisLabelDescriptor(name="Regular", userValue=400, elidable=True) | |
] | |
doc.addAxis(a1) | |
""" | |
_attrs = [ | |
"tag", | |
"name", | |
"maximum", | |
"minimum", | |
"default", | |
"map", | |
"axisOrdering", | |
"axisLabels", | |
] | |
def __init__( | |
self, | |
*, | |
tag=None, | |
name=None, | |
labelNames=None, | |
minimum=None, | |
default=None, | |
maximum=None, | |
hidden=False, | |
map=None, | |
axisOrdering=None, | |
axisLabels=None, | |
): | |
super().__init__( | |
tag=tag, | |
name=name, | |
labelNames=labelNames, | |
hidden=hidden, | |
map=map, | |
axisOrdering=axisOrdering, | |
axisLabels=axisLabels, | |
) | |
self.minimum = minimum | |
"""number. The minimum value for this axis in user space. | |
MutatorMath + varLib. | |
""" | |
self.maximum = maximum | |
"""number. The maximum value for this axis in user space. | |
MutatorMath + varLib. | |
""" | |
self.default = default | |
"""number. The default value for this axis, i.e. when a new location is | |
created, this is the value this axis will get in user space. | |
MutatorMath + varLib. | |
""" | |
def serialize(self): | |
# output to a dict, used in testing | |
return dict( | |
tag=self.tag, | |
name=self.name, | |
labelNames=self.labelNames, | |
maximum=self.maximum, | |
minimum=self.minimum, | |
default=self.default, | |
hidden=self.hidden, | |
map=self.map, | |
axisOrdering=self.axisOrdering, | |
axisLabels=self.axisLabels, | |
) | |
def map_forward(self, v): | |
"""Maps value from axis mapping's input (user) to output (design).""" | |
from fontTools.varLib.models import piecewiseLinearMap | |
if not self.map: | |
return v | |
return piecewiseLinearMap(v, {k: v for k, v in self.map}) | |
def map_backward(self, v): | |
"""Maps value from axis mapping's output (design) to input (user).""" | |
from fontTools.varLib.models import piecewiseLinearMap | |
if isinstance(v, tuple): | |
v = v[0] | |
if not self.map: | |
return v | |
return piecewiseLinearMap(v, {v: k for k, v in self.map}) | |
class DiscreteAxisDescriptor(AbstractAxisDescriptor): | |
"""Container for discrete axis data. | |
Use this for axes that do not interpolate. The main difference from a | |
continuous axis is that a continuous axis has a ``minimum`` and ``maximum``, | |
while a discrete axis has a list of ``values``. | |
Example: an Italic axis with 2 stops, Roman and Italic, that are not | |
compatible. The axis still allows to bind together the full font family, | |
which is useful for the STAT table, however it can't become a variation | |
axis in a VF. | |
.. code:: python | |
a2 = DiscreteAxisDescriptor() | |
a2.values = [0, 1] | |
a2.default = 0 | |
a2.name = "Italic" | |
a2.tag = "ITAL" | |
a2.labelNames['fr'] = "Italique" | |
a2.map = [(0, 0), (1, -11)] | |
a2.axisOrdering = 2 | |
a2.axisLabels = [ | |
AxisLabelDescriptor(name="Roman", userValue=0, elidable=True) | |
] | |
doc.addAxis(a2) | |
.. versionadded:: 5.0 | |
""" | |
flavor = "axis" | |
_attrs = ("tag", "name", "values", "default", "map", "axisOrdering", "axisLabels") | |
def __init__( | |
self, | |
*, | |
tag=None, | |
name=None, | |
labelNames=None, | |
values=None, | |
default=None, | |
hidden=False, | |
map=None, | |
axisOrdering=None, | |
axisLabels=None, | |
): | |
super().__init__( | |
tag=tag, | |
name=name, | |
labelNames=labelNames, | |
hidden=hidden, | |
map=map, | |
axisOrdering=axisOrdering, | |
axisLabels=axisLabels, | |
) | |
self.default: float = default | |
"""The default value for this axis, i.e. when a new location is | |
created, this is the value this axis will get in user space. | |
However, this default value is less important than in continuous axes: | |
- it doesn't define the "neutral" version of outlines from which | |
deltas would apply, as this axis does not interpolate. | |
- it doesn't provide the reference glyph set for the designspace, as | |
fonts at each value can have different glyph sets. | |
""" | |
self.values: List[float] = values or [] | |
"""List of possible values for this axis. Contrary to continuous axes, | |
only the values in this list can be taken by the axis, nothing in-between. | |
""" | |
def map_forward(self, value): | |
"""Maps value from axis mapping's input to output. | |
Returns value unchanged if no mapping entry is found. | |
Note: for discrete axes, each value must have its mapping entry, if | |
you intend that value to be mapped. | |
""" | |
return next((v for k, v in self.map if k == value), value) | |
def map_backward(self, value): | |
"""Maps value from axis mapping's output to input. | |
Returns value unchanged if no mapping entry is found. | |
Note: for discrete axes, each value must have its mapping entry, if | |
you intend that value to be mapped. | |
""" | |
if isinstance(value, tuple): | |
value = value[0] | |
return next((k for k, v in self.map if v == value), value) | |
class AxisLabelDescriptor(SimpleDescriptor): | |
"""Container for axis label data. | |
Analogue of OpenType's STAT data for a single axis (formats 1, 2 and 3). | |
All values are user values. | |
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>`_ | |
The STAT format of the Axis value depends on which field are filled-in, | |
see :meth:`getFormat` | |
.. versionadded:: 5.0 | |
""" | |
flavor = "label" | |
_attrs = ( | |
"userMinimum", | |
"userValue", | |
"userMaximum", | |
"name", | |
"elidable", | |
"olderSibling", | |
"linkedUserValue", | |
"labelNames", | |
) | |
def __init__( | |
self, | |
*, | |
name, | |
userValue, | |
userMinimum=None, | |
userMaximum=None, | |
elidable=False, | |
olderSibling=False, | |
linkedUserValue=None, | |
labelNames=None, | |
): | |
self.userMinimum: Optional[float] = userMinimum | |
"""STAT field ``rangeMinValue`` (format 2).""" | |
self.userValue: float = userValue | |
"""STAT field ``value`` (format 1, 3) or ``nominalValue`` (format 2).""" | |
self.userMaximum: Optional[float] = userMaximum | |
"""STAT field ``rangeMaxValue`` (format 2).""" | |
self.name: str = name | |
"""Label for this axis location, STAT field ``valueNameID``.""" | |
self.elidable: bool = elidable | |
"""STAT flag ``ELIDABLE_AXIS_VALUE_NAME``. | |
See: `OTSpec STAT Flags <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#flags>`_ | |
""" | |
self.olderSibling: bool = olderSibling | |
"""STAT flag ``OLDER_SIBLING_FONT_ATTRIBUTE``. | |
See: `OTSpec STAT Flags <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#flags>`_ | |
""" | |
self.linkedUserValue: Optional[float] = linkedUserValue | |
"""STAT field ``linkedValue`` (format 3).""" | |
self.labelNames: MutableMapping[str, str] = labelNames or {} | |
"""User-facing translations of this location's label. Keyed by | |
``xml:lang`` code. | |
""" | |
def getFormat(self) -> int: | |
"""Determine which format of STAT Axis value to use to encode this label. | |
=========== ========= =========== =========== =============== | |
STAT Format userValue userMinimum userMaximum linkedUserValue | |
=========== ========= =========== =========== =============== | |
1 ✅ ❌ ❌ ❌ | |
2 ✅ ✅ ✅ ❌ | |
3 ✅ ❌ ❌ ✅ | |
=========== ========= =========== =========== =============== | |
""" | |
if self.linkedUserValue is not None: | |
return 3 | |
if self.userMinimum is not None or self.userMaximum is not None: | |
return 2 | |
return 1 | |
def defaultName(self) -> str: | |
"""Return the English name from :attr:`labelNames` or the :attr:`name`.""" | |
return self.labelNames.get("en") or self.name | |
class LocationLabelDescriptor(SimpleDescriptor): | |
"""Container for location label data. | |
Analogue of OpenType's STAT data for a free-floating location (format 4). | |
All values are user values. | |
See: `OTSpec STAT Axis value table, format 4 <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#axis-value-table-format-4>`_ | |
.. versionadded:: 5.0 | |
""" | |
flavor = "label" | |
_attrs = ("name", "elidable", "olderSibling", "userLocation", "labelNames") | |
def __init__( | |
self, | |
*, | |
name, | |
userLocation, | |
elidable=False, | |
olderSibling=False, | |
labelNames=None, | |
): | |
self.name: str = name | |
"""Label for this named location, STAT field ``valueNameID``.""" | |
self.userLocation: SimpleLocationDict = userLocation or {} | |
"""Location in user coordinates along each axis. | |
If an axis is not mentioned, it is assumed to be at its default location. | |
.. seealso:: This may be only part of the full location. See: | |
:meth:`getFullUserLocation` | |
""" | |
self.elidable: bool = elidable | |
"""STAT flag ``ELIDABLE_AXIS_VALUE_NAME``. | |
See: `OTSpec STAT Flags <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#flags>`_ | |
""" | |
self.olderSibling: bool = olderSibling | |
"""STAT flag ``OLDER_SIBLING_FONT_ATTRIBUTE``. | |
See: `OTSpec STAT Flags <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#flags>`_ | |
""" | |
self.labelNames: Dict[str, str] = labelNames or {} | |
"""User-facing translations of this location's label. Keyed by | |
xml:lang code. | |
""" | |
def defaultName(self) -> str: | |
"""Return the English name from :attr:`labelNames` or the :attr:`name`.""" | |
return self.labelNames.get("en") or self.name | |
def getFullUserLocation(self, doc: "DesignSpaceDocument") -> SimpleLocationDict: | |
"""Get the complete user location of this label, by combining data | |
from the explicit user location and default axis values. | |
.. versionadded:: 5.0 | |
""" | |
return { | |
axis.name: self.userLocation.get(axis.name, axis.default) | |
for axis in doc.axes | |
} | |
class VariableFontDescriptor(SimpleDescriptor): | |
"""Container for variable fonts, sub-spaces of the Designspace. | |
Use-cases: | |
- From a single DesignSpace with discrete axes, define 1 variable font | |
per value on the discrete axes. Before version 5, you would have needed | |
1 DesignSpace per such variable font, and a lot of data duplication. | |
- From a big variable font with many axes, define subsets of that variable | |
font that only include some axes and freeze other axes at a given location. | |
.. versionadded:: 5.0 | |
""" | |
flavor = "variable-font" | |
_attrs = ("filename", "axisSubsets", "lib") | |
filename = posixpath_property("_filename") | |
def __init__(self, *, name, filename=None, axisSubsets=None, lib=None): | |
self.name: str = name | |
"""string, required. Name of this variable to identify it during the | |
build process and from other parts of the document, and also as a | |
filename in case the filename property is empty. | |
VarLib. | |
""" | |
self.filename: str = filename | |
"""string, optional. Relative path to the variable font file, **as it is | |
in the document**. The file may or may not exist. | |
If not specified, the :attr:`name` will be used as a basename for the file. | |
""" | |
self.axisSubsets: List[ | |
Union[RangeAxisSubsetDescriptor, ValueAxisSubsetDescriptor] | |
] = (axisSubsets or []) | |
"""Axis subsets to include in this variable font. | |
If an axis is not mentioned, assume that we only want the default | |
location of that axis (same as a :class:`ValueAxisSubsetDescriptor`). | |
""" | |
self.lib: MutableMapping[str, Any] = lib or {} | |
"""Custom data associated with this variable font.""" | |
class RangeAxisSubsetDescriptor(SimpleDescriptor): | |
"""Subset of a continuous axis to include in a variable font. | |
.. versionadded:: 5.0 | |
""" | |
flavor = "axis-subset" | |
_attrs = ("name", "userMinimum", "userDefault", "userMaximum") | |
def __init__( | |
self, *, name, userMinimum=-math.inf, userDefault=None, userMaximum=math.inf | |
): | |
self.name: str = name | |
"""Name of the :class:`AxisDescriptor` to subset.""" | |
self.userMinimum: float = userMinimum | |
"""New minimum value of the axis in the target variable font. | |
If not specified, assume the same minimum value as the full axis. | |
(default = ``-math.inf``) | |
""" | |
self.userDefault: Optional[float] = userDefault | |
"""New default value of the axis in the target variable font. | |
If not specified, assume the same default value as the full axis. | |
(default = ``None``) | |
""" | |
self.userMaximum: float = userMaximum | |
"""New maximum value of the axis in the target variable font. | |
If not specified, assume the same maximum value as the full axis. | |
(default = ``math.inf``) | |
""" | |
class ValueAxisSubsetDescriptor(SimpleDescriptor): | |
"""Single value of a discrete or continuous axis to use in a variable font. | |
.. versionadded:: 5.0 | |
""" | |
flavor = "axis-subset" | |
_attrs = ("name", "userValue") | |
def __init__(self, *, name, userValue): | |
self.name: str = name | |
"""Name of the :class:`AxisDescriptor` or :class:`DiscreteAxisDescriptor` | |
to "snapshot" or "freeze". | |
""" | |
self.userValue: float = userValue | |
"""Value in user coordinates at which to freeze the given axis.""" | |
class BaseDocWriter(object): | |
_whiteSpace = " " | |
axisDescriptorClass = AxisDescriptor | |
discreteAxisDescriptorClass = DiscreteAxisDescriptor | |
axisLabelDescriptorClass = AxisLabelDescriptor | |
axisMappingDescriptorClass = AxisMappingDescriptor | |
locationLabelDescriptorClass = LocationLabelDescriptor | |
ruleDescriptorClass = RuleDescriptor | |
sourceDescriptorClass = SourceDescriptor | |
variableFontDescriptorClass = VariableFontDescriptor | |
valueAxisSubsetDescriptorClass = ValueAxisSubsetDescriptor | |
rangeAxisSubsetDescriptorClass = RangeAxisSubsetDescriptor | |
instanceDescriptorClass = InstanceDescriptor | |
def getAxisDecriptor(cls): | |
return cls.axisDescriptorClass() | |
def getAxisMappingDescriptor(cls): | |
return cls.axisMappingDescriptorClass() | |
def getSourceDescriptor(cls): | |
return cls.sourceDescriptorClass() | |
def getInstanceDescriptor(cls): | |
return cls.instanceDescriptorClass() | |
def getRuleDescriptor(cls): | |
return cls.ruleDescriptorClass() | |
def __init__(self, documentPath, documentObject: DesignSpaceDocument): | |
self.path = documentPath | |
self.documentObject = documentObject | |
self.effectiveFormatTuple = self._getEffectiveFormatTuple() | |
self.root = ET.Element("designspace") | |
def write(self, pretty=True, encoding="UTF-8", xml_declaration=True): | |
self.root.attrib["format"] = ".".join(str(i) for i in self.effectiveFormatTuple) | |
if ( | |
self.documentObject.axes | |
or self.documentObject.axisMappings | |
or self.documentObject.elidedFallbackName is not None | |
): | |
axesElement = ET.Element("axes") | |
if self.documentObject.elidedFallbackName is not None: | |
axesElement.attrib["elidedfallbackname"] = ( | |
self.documentObject.elidedFallbackName | |
) | |
self.root.append(axesElement) | |
for axisObject in self.documentObject.axes: | |
self._addAxis(axisObject) | |
if self.documentObject.axisMappings: | |
mappingsElement = None | |
lastGroup = object() | |
for mappingObject in self.documentObject.axisMappings: | |
if getattr(mappingObject, "groupDescription", None) != lastGroup: | |
if mappingsElement is not None: | |
self.root.findall(".axes")[0].append(mappingsElement) | |
lastGroup = getattr(mappingObject, "groupDescription", None) | |
mappingsElement = ET.Element("mappings") | |
if lastGroup is not None: | |
mappingsElement.attrib["description"] = lastGroup | |
self._addAxisMapping(mappingsElement, mappingObject) | |
if mappingsElement is not None: | |
self.root.findall(".axes")[0].append(mappingsElement) | |
if self.documentObject.locationLabels: | |
labelsElement = ET.Element("labels") | |
for labelObject in self.documentObject.locationLabels: | |
self._addLocationLabel(labelsElement, labelObject) | |
self.root.append(labelsElement) | |
if self.documentObject.rules: | |
if getattr(self.documentObject, "rulesProcessingLast", False): | |
attributes = {"processing": "last"} | |
else: | |
attributes = {} | |
self.root.append(ET.Element("rules", attributes)) | |
for ruleObject in self.documentObject.rules: | |
self._addRule(ruleObject) | |
if self.documentObject.sources: | |
self.root.append(ET.Element("sources")) | |
for sourceObject in self.documentObject.sources: | |
self._addSource(sourceObject) | |
if self.documentObject.variableFonts: | |
variableFontsElement = ET.Element("variable-fonts") | |
for variableFont in self.documentObject.variableFonts: | |
self._addVariableFont(variableFontsElement, variableFont) | |
self.root.append(variableFontsElement) | |
if self.documentObject.instances: | |
self.root.append(ET.Element("instances")) | |
for instanceObject in self.documentObject.instances: | |
self._addInstance(instanceObject) | |
if self.documentObject.lib: | |
self._addLib(self.root, self.documentObject.lib, 2) | |
tree = ET.ElementTree(self.root) | |
tree.write( | |
self.path, | |
encoding=encoding, | |
method="xml", | |
xml_declaration=xml_declaration, | |
pretty_print=pretty, | |
) | |
def _getEffectiveFormatTuple(self): | |
"""Try to use the version specified in the document, or a sufficiently | |
recent version to be able to encode what the document contains. | |
""" | |
minVersion = self.documentObject.formatTuple | |
if ( | |
any( | |
hasattr(axis, "values") | |
or axis.axisOrdering is not None | |
or axis.axisLabels | |
for axis in self.documentObject.axes | |
) | |
or self.documentObject.locationLabels | |
or any(source.localisedFamilyName for source in self.documentObject.sources) | |
or self.documentObject.variableFonts | |
or any( | |
instance.locationLabel or instance.userLocation | |
for instance in self.documentObject.instances | |
) | |
): | |
if minVersion < (5, 0): | |
minVersion = (5, 0) | |
if self.documentObject.axisMappings: | |
if minVersion < (5, 1): | |
minVersion = (5, 1) | |
return minVersion | |
def _makeLocationElement(self, locationObject, name=None): | |
"""Convert Location dict to a locationElement.""" | |
locElement = ET.Element("location") | |
if name is not None: | |
locElement.attrib["name"] = name | |
validatedLocation = self.documentObject.newDefaultLocation() | |
for axisName, axisValue in locationObject.items(): | |
if axisName in validatedLocation: | |
# only accept values we know | |
validatedLocation[axisName] = axisValue | |
for dimensionName, dimensionValue in validatedLocation.items(): | |
dimElement = ET.Element("dimension") | |
dimElement.attrib["name"] = dimensionName | |
if type(dimensionValue) == tuple: | |
dimElement.attrib["xvalue"] = self.intOrFloat(dimensionValue[0]) | |
dimElement.attrib["yvalue"] = self.intOrFloat(dimensionValue[1]) | |
else: | |
dimElement.attrib["xvalue"] = self.intOrFloat(dimensionValue) | |
locElement.append(dimElement) | |
return locElement, validatedLocation | |
def intOrFloat(self, num): | |
if int(num) == num: | |
return "%d" % num | |
return ("%f" % num).rstrip("0").rstrip(".") | |
def _addRule(self, ruleObject): | |
# if none of the conditions have minimum or maximum values, do not add the rule. | |
ruleElement = ET.Element("rule") | |
if ruleObject.name is not None: | |
ruleElement.attrib["name"] = ruleObject.name | |
for conditions in ruleObject.conditionSets: | |
conditionsetElement = ET.Element("conditionset") | |
for cond in conditions: | |
if cond.get("minimum") is None and cond.get("maximum") is None: | |
# neither is defined, don't add this condition | |
continue | |
conditionElement = ET.Element("condition") | |
conditionElement.attrib["name"] = cond.get("name") | |
if cond.get("minimum") is not None: | |
conditionElement.attrib["minimum"] = self.intOrFloat( | |
cond.get("minimum") | |
) | |
if cond.get("maximum") is not None: | |
conditionElement.attrib["maximum"] = self.intOrFloat( | |
cond.get("maximum") | |
) | |
conditionsetElement.append(conditionElement) | |
if len(conditionsetElement): | |
ruleElement.append(conditionsetElement) | |
for sub in ruleObject.subs: | |
subElement = ET.Element("sub") | |
subElement.attrib["name"] = sub[0] | |
subElement.attrib["with"] = sub[1] | |
ruleElement.append(subElement) | |
if len(ruleElement): | |
self.root.findall(".rules")[0].append(ruleElement) | |
def _addAxis(self, axisObject): | |
axisElement = ET.Element("axis") | |
axisElement.attrib["tag"] = axisObject.tag | |
axisElement.attrib["name"] = axisObject.name | |
self._addLabelNames(axisElement, axisObject.labelNames) | |
if axisObject.map: | |
for inputValue, outputValue in axisObject.map: | |
mapElement = ET.Element("map") | |
mapElement.attrib["input"] = self.intOrFloat(inputValue) | |
mapElement.attrib["output"] = self.intOrFloat(outputValue) | |
axisElement.append(mapElement) | |
if axisObject.axisOrdering or axisObject.axisLabels: | |
labelsElement = ET.Element("labels") | |
if axisObject.axisOrdering is not None: | |
labelsElement.attrib["ordering"] = str(axisObject.axisOrdering) | |
for label in axisObject.axisLabels: | |
self._addAxisLabel(labelsElement, label) | |
axisElement.append(labelsElement) | |
if hasattr(axisObject, "minimum"): | |
axisElement.attrib["minimum"] = self.intOrFloat(axisObject.minimum) | |
axisElement.attrib["maximum"] = self.intOrFloat(axisObject.maximum) | |
elif hasattr(axisObject, "values"): | |
axisElement.attrib["values"] = " ".join( | |
self.intOrFloat(v) for v in axisObject.values | |
) | |
axisElement.attrib["default"] = self.intOrFloat(axisObject.default) | |
if axisObject.hidden: | |
axisElement.attrib["hidden"] = "1" | |
self.root.findall(".axes")[0].append(axisElement) | |
def _addAxisMapping(self, mappingsElement, mappingObject): | |
mappingElement = ET.Element("mapping") | |
if getattr(mappingObject, "description", None) is not None: | |
mappingElement.attrib["description"] = mappingObject.description | |
for what in ("inputLocation", "outputLocation"): | |
whatObject = getattr(mappingObject, what, None) | |
if whatObject is None: | |
continue | |
whatElement = ET.Element(what[:-8]) | |
mappingElement.append(whatElement) | |
for name, value in whatObject.items(): | |
dimensionElement = ET.Element("dimension") | |
dimensionElement.attrib["name"] = name | |
dimensionElement.attrib["xvalue"] = self.intOrFloat(value) | |
whatElement.append(dimensionElement) | |
mappingsElement.append(mappingElement) | |
def _addAxisLabel( | |
self, axisElement: ET.Element, label: AxisLabelDescriptor | |
) -> None: | |
labelElement = ET.Element("label") | |
labelElement.attrib["uservalue"] = self.intOrFloat(label.userValue) | |
if label.userMinimum is not None: | |
labelElement.attrib["userminimum"] = self.intOrFloat(label.userMinimum) | |
if label.userMaximum is not None: | |
labelElement.attrib["usermaximum"] = self.intOrFloat(label.userMaximum) | |
labelElement.attrib["name"] = label.name | |
if label.elidable: | |
labelElement.attrib["elidable"] = "true" | |
if label.olderSibling: | |
labelElement.attrib["oldersibling"] = "true" | |
if label.linkedUserValue is not None: | |
labelElement.attrib["linkeduservalue"] = self.intOrFloat( | |
label.linkedUserValue | |
) | |
self._addLabelNames(labelElement, label.labelNames) | |
axisElement.append(labelElement) | |
def _addLabelNames(self, parentElement, labelNames): | |
for languageCode, labelName in sorted(labelNames.items()): | |
languageElement = ET.Element("labelname") | |
languageElement.attrib[XML_LANG] = languageCode | |
languageElement.text = labelName | |
parentElement.append(languageElement) | |
def _addLocationLabel( | |
self, parentElement: ET.Element, label: LocationLabelDescriptor | |
) -> None: | |
labelElement = ET.Element("label") | |
labelElement.attrib["name"] = label.name | |
if label.elidable: | |
labelElement.attrib["elidable"] = "true" | |
if label.olderSibling: | |
labelElement.attrib["oldersibling"] = "true" | |
self._addLabelNames(labelElement, label.labelNames) | |
self._addLocationElement(labelElement, userLocation=label.userLocation) | |
parentElement.append(labelElement) | |
def _addLocationElement( | |
self, | |
parentElement, | |
*, | |
designLocation: AnisotropicLocationDict = None, | |
userLocation: SimpleLocationDict = None, | |
): | |
locElement = ET.Element("location") | |
for axis in self.documentObject.axes: | |
if designLocation is not None and axis.name in designLocation: | |
dimElement = ET.Element("dimension") | |
dimElement.attrib["name"] = axis.name | |
value = designLocation[axis.name] | |
if isinstance(value, tuple): | |
dimElement.attrib["xvalue"] = self.intOrFloat(value[0]) | |
dimElement.attrib["yvalue"] = self.intOrFloat(value[1]) | |
else: | |
dimElement.attrib["xvalue"] = self.intOrFloat(value) | |
locElement.append(dimElement) | |
elif userLocation is not None and axis.name in userLocation: | |
dimElement = ET.Element("dimension") | |
dimElement.attrib["name"] = axis.name | |
value = userLocation[axis.name] | |
dimElement.attrib["uservalue"] = self.intOrFloat(value) | |
locElement.append(dimElement) | |
if len(locElement) > 0: | |
parentElement.append(locElement) | |
def _addInstance(self, instanceObject): | |
instanceElement = ET.Element("instance") | |
if instanceObject.name is not None: | |
instanceElement.attrib["name"] = instanceObject.name | |
if instanceObject.locationLabel is not None: | |
instanceElement.attrib["location"] = instanceObject.locationLabel | |
if instanceObject.familyName is not None: | |
instanceElement.attrib["familyname"] = instanceObject.familyName | |
if instanceObject.styleName is not None: | |
instanceElement.attrib["stylename"] = instanceObject.styleName | |
# add localisations | |
if instanceObject.localisedStyleName: | |
languageCodes = list(instanceObject.localisedStyleName.keys()) | |
languageCodes.sort() | |
for code in languageCodes: | |
if code == "en": | |
continue # already stored in the element attribute | |
localisedStyleNameElement = ET.Element("stylename") | |
localisedStyleNameElement.attrib[XML_LANG] = code | |
localisedStyleNameElement.text = instanceObject.getStyleName(code) | |
instanceElement.append(localisedStyleNameElement) | |
if instanceObject.localisedFamilyName: | |
languageCodes = list(instanceObject.localisedFamilyName.keys()) | |
languageCodes.sort() | |
for code in languageCodes: | |
if code == "en": | |
continue # already stored in the element attribute | |
localisedFamilyNameElement = ET.Element("familyname") | |
localisedFamilyNameElement.attrib[XML_LANG] = code | |
localisedFamilyNameElement.text = instanceObject.getFamilyName(code) | |
instanceElement.append(localisedFamilyNameElement) | |
if instanceObject.localisedStyleMapStyleName: | |
languageCodes = list(instanceObject.localisedStyleMapStyleName.keys()) | |
languageCodes.sort() | |
for code in languageCodes: | |
if code == "en": | |
continue | |
localisedStyleMapStyleNameElement = ET.Element("stylemapstylename") | |
localisedStyleMapStyleNameElement.attrib[XML_LANG] = code | |
localisedStyleMapStyleNameElement.text = ( | |
instanceObject.getStyleMapStyleName(code) | |
) | |
instanceElement.append(localisedStyleMapStyleNameElement) | |
if instanceObject.localisedStyleMapFamilyName: | |
languageCodes = list(instanceObject.localisedStyleMapFamilyName.keys()) | |
languageCodes.sort() | |
for code in languageCodes: | |
if code == "en": | |
continue | |
localisedStyleMapFamilyNameElement = ET.Element("stylemapfamilyname") | |
localisedStyleMapFamilyNameElement.attrib[XML_LANG] = code | |
localisedStyleMapFamilyNameElement.text = ( | |
instanceObject.getStyleMapFamilyName(code) | |
) | |
instanceElement.append(localisedStyleMapFamilyNameElement) | |
if self.effectiveFormatTuple >= (5, 0): | |
if instanceObject.locationLabel is None: | |
self._addLocationElement( | |
instanceElement, | |
designLocation=instanceObject.designLocation, | |
userLocation=instanceObject.userLocation, | |
) | |
else: | |
# Pre-version 5.0 code was validating and filling in the location | |
# dict while writing it out, as preserved below. | |
if instanceObject.location is not None: | |
locationElement, instanceObject.location = self._makeLocationElement( | |
instanceObject.location | |
) | |
instanceElement.append(locationElement) | |
if instanceObject.filename is not None: | |
instanceElement.attrib["filename"] = instanceObject.filename | |
if instanceObject.postScriptFontName is not None: | |
instanceElement.attrib["postscriptfontname"] = ( | |
instanceObject.postScriptFontName | |
) | |
if instanceObject.styleMapFamilyName is not None: | |
instanceElement.attrib["stylemapfamilyname"] = ( | |
instanceObject.styleMapFamilyName | |
) | |
if instanceObject.styleMapStyleName is not None: | |
instanceElement.attrib["stylemapstylename"] = ( | |
instanceObject.styleMapStyleName | |
) | |
if self.effectiveFormatTuple < (5, 0): | |
# Deprecated members as of version 5.0 | |
if instanceObject.glyphs: | |
if instanceElement.findall(".glyphs") == []: | |
glyphsElement = ET.Element("glyphs") | |
instanceElement.append(glyphsElement) | |
glyphsElement = instanceElement.findall(".glyphs")[0] | |
for glyphName, data in sorted(instanceObject.glyphs.items()): | |
glyphElement = self._writeGlyphElement( | |
instanceElement, instanceObject, glyphName, data | |
) | |
glyphsElement.append(glyphElement) | |
if instanceObject.kerning: | |
kerningElement = ET.Element("kerning") | |
instanceElement.append(kerningElement) | |
if instanceObject.info: | |
infoElement = ET.Element("info") | |
instanceElement.append(infoElement) | |
self._addLib(instanceElement, instanceObject.lib, 4) | |
self.root.findall(".instances")[0].append(instanceElement) | |
def _addSource(self, sourceObject): | |
sourceElement = ET.Element("source") | |
if sourceObject.filename is not None: | |
sourceElement.attrib["filename"] = sourceObject.filename | |
if sourceObject.name is not None: | |
if sourceObject.name.find("temp_master") != 0: | |
# do not save temporary source names | |
sourceElement.attrib["name"] = sourceObject.name | |
if sourceObject.familyName is not None: | |
sourceElement.attrib["familyname"] = sourceObject.familyName | |
if sourceObject.styleName is not None: | |
sourceElement.attrib["stylename"] = sourceObject.styleName | |
if sourceObject.layerName is not None: | |
sourceElement.attrib["layer"] = sourceObject.layerName | |
if sourceObject.localisedFamilyName: | |
languageCodes = list(sourceObject.localisedFamilyName.keys()) | |
languageCodes.sort() | |
for code in languageCodes: | |
if code == "en": | |
continue # already stored in the element attribute | |
localisedFamilyNameElement = ET.Element("familyname") | |
localisedFamilyNameElement.attrib[XML_LANG] = code | |
localisedFamilyNameElement.text = sourceObject.getFamilyName(code) | |
sourceElement.append(localisedFamilyNameElement) | |
if sourceObject.copyLib: | |
libElement = ET.Element("lib") | |
libElement.attrib["copy"] = "1" | |
sourceElement.append(libElement) | |
if sourceObject.copyGroups: | |
groupsElement = ET.Element("groups") | |
groupsElement.attrib["copy"] = "1" | |
sourceElement.append(groupsElement) | |
if sourceObject.copyFeatures: | |
featuresElement = ET.Element("features") | |
featuresElement.attrib["copy"] = "1" | |
sourceElement.append(featuresElement) | |
if sourceObject.copyInfo or sourceObject.muteInfo: | |
infoElement = ET.Element("info") | |
if sourceObject.copyInfo: | |
infoElement.attrib["copy"] = "1" | |
if sourceObject.muteInfo: | |
infoElement.attrib["mute"] = "1" | |
sourceElement.append(infoElement) | |
if sourceObject.muteKerning: | |
kerningElement = ET.Element("kerning") | |
kerningElement.attrib["mute"] = "1" | |
sourceElement.append(kerningElement) | |
if sourceObject.mutedGlyphNames: | |
for name in sourceObject.mutedGlyphNames: | |
glyphElement = ET.Element("glyph") | |
glyphElement.attrib["name"] = name | |
glyphElement.attrib["mute"] = "1" | |
sourceElement.append(glyphElement) | |
if self.effectiveFormatTuple >= (5, 0): | |
self._addLocationElement( | |
sourceElement, designLocation=sourceObject.location | |
) | |
else: | |
# Pre-version 5.0 code was validating and filling in the location | |
# dict while writing it out, as preserved below. | |
locationElement, sourceObject.location = self._makeLocationElement( | |
sourceObject.location | |
) | |
sourceElement.append(locationElement) | |
self.root.findall(".sources")[0].append(sourceElement) | |
def _addVariableFont( | |
self, parentElement: ET.Element, vf: VariableFontDescriptor | |
) -> None: | |
vfElement = ET.Element("variable-font") | |
vfElement.attrib["name"] = vf.name | |
if vf.filename is not None: | |
vfElement.attrib["filename"] = vf.filename | |
if vf.axisSubsets: | |
subsetsElement = ET.Element("axis-subsets") | |
for subset in vf.axisSubsets: | |
subsetElement = ET.Element("axis-subset") | |
subsetElement.attrib["name"] = subset.name | |
# Mypy doesn't support narrowing union types via hasattr() | |
# https://mypy.readthedocs.io/en/stable/type_narrowing.html | |
# TODO(Python 3.10): use TypeGuard | |
if hasattr(subset, "userMinimum"): | |
subset = cast(RangeAxisSubsetDescriptor, subset) | |
if subset.userMinimum != -math.inf: | |
subsetElement.attrib["userminimum"] = self.intOrFloat( | |
subset.userMinimum | |
) | |
if subset.userMaximum != math.inf: | |
subsetElement.attrib["usermaximum"] = self.intOrFloat( | |
subset.userMaximum | |
) | |
if subset.userDefault is not None: | |
subsetElement.attrib["userdefault"] = self.intOrFloat( | |
subset.userDefault | |
) | |
elif hasattr(subset, "userValue"): | |
subset = cast(ValueAxisSubsetDescriptor, subset) | |
subsetElement.attrib["uservalue"] = self.intOrFloat( | |
subset.userValue | |
) | |
subsetsElement.append(subsetElement) | |
vfElement.append(subsetsElement) | |
self._addLib(vfElement, vf.lib, 4) | |
parentElement.append(vfElement) | |
def _addLib(self, parentElement: ET.Element, data: Any, indent_level: int) -> None: | |
if not data: | |
return | |
libElement = ET.Element("lib") | |
libElement.append(plistlib.totree(data, indent_level=indent_level)) | |
parentElement.append(libElement) | |
def _writeGlyphElement(self, instanceElement, instanceObject, glyphName, data): | |
glyphElement = ET.Element("glyph") | |
if data.get("mute"): | |
glyphElement.attrib["mute"] = "1" | |
if data.get("unicodes") is not None: | |
glyphElement.attrib["unicode"] = " ".join( | |
[hex(u) for u in data.get("unicodes")] | |
) | |
if data.get("instanceLocation") is not None: | |
locationElement, data["instanceLocation"] = self._makeLocationElement( | |
data.get("instanceLocation") | |
) | |
glyphElement.append(locationElement) | |
if glyphName is not None: | |
glyphElement.attrib["name"] = glyphName | |
if data.get("note") is not None: | |
noteElement = ET.Element("note") | |
noteElement.text = data.get("note") | |
glyphElement.append(noteElement) | |
if data.get("masters") is not None: | |
mastersElement = ET.Element("masters") | |
for m in data.get("masters"): | |
masterElement = ET.Element("master") | |
if m.get("glyphName") is not None: | |
masterElement.attrib["glyphname"] = m.get("glyphName") | |
if m.get("font") is not None: | |
masterElement.attrib["source"] = m.get("font") | |
if m.get("location") is not None: | |
locationElement, m["location"] = self._makeLocationElement( | |
m.get("location") | |
) | |
masterElement.append(locationElement) | |
mastersElement.append(masterElement) | |
glyphElement.append(mastersElement) | |
return glyphElement | |
class BaseDocReader(LogMixin): | |
axisDescriptorClass = AxisDescriptor | |
discreteAxisDescriptorClass = DiscreteAxisDescriptor | |
axisLabelDescriptorClass = AxisLabelDescriptor | |
axisMappingDescriptorClass = AxisMappingDescriptor | |
locationLabelDescriptorClass = LocationLabelDescriptor | |
ruleDescriptorClass = RuleDescriptor | |
sourceDescriptorClass = SourceDescriptor | |
variableFontsDescriptorClass = VariableFontDescriptor | |
valueAxisSubsetDescriptorClass = ValueAxisSubsetDescriptor | |
rangeAxisSubsetDescriptorClass = RangeAxisSubsetDescriptor | |
instanceDescriptorClass = InstanceDescriptor | |
def __init__(self, documentPath, documentObject): | |
self.path = documentPath | |
self.documentObject = documentObject | |
tree = ET.parse(self.path) | |
self.root = tree.getroot() | |
self.documentObject.formatVersion = self.root.attrib.get("format", "3.0") | |
self._axes = [] | |
self.rules = [] | |
self.sources = [] | |
self.instances = [] | |
self.axisDefaults = {} | |
self._strictAxisNames = True | |
def fromstring(cls, string, documentObject): | |
f = BytesIO(tobytes(string, encoding="utf-8")) | |
self = cls(f, documentObject) | |
self.path = None | |
return self | |
def read(self): | |
self.readAxes() | |
self.readLabels() | |
self.readRules() | |
self.readVariableFonts() | |
self.readSources() | |
self.readInstances() | |
self.readLib() | |
def readRules(self): | |
# we also need to read any conditions that are outside of a condition set. | |
rules = [] | |
rulesElement = self.root.find(".rules") | |
if rulesElement is not None: | |
processingValue = rulesElement.attrib.get("processing", "first") | |
if processingValue not in {"first", "last"}: | |
raise DesignSpaceDocumentError( | |
"<rules> processing attribute value is not valid: %r, " | |
"expected 'first' or 'last'" % processingValue | |
) | |
self.documentObject.rulesProcessingLast = processingValue == "last" | |
for ruleElement in self.root.findall(".rules/rule"): | |
ruleObject = self.ruleDescriptorClass() | |
ruleName = ruleObject.name = ruleElement.attrib.get("name") | |
# read any stray conditions outside a condition set | |
externalConditions = self._readConditionElements( | |
ruleElement, | |
ruleName, | |
) | |
if externalConditions: | |
ruleObject.conditionSets.append(externalConditions) | |
self.log.info( | |
"Found stray rule conditions outside a conditionset. " | |
"Wrapped them in a new conditionset." | |
) | |
# read the conditionsets | |
for conditionSetElement in ruleElement.findall(".conditionset"): | |
conditionSet = self._readConditionElements( | |
conditionSetElement, | |
ruleName, | |
) | |
if conditionSet is not None: | |
ruleObject.conditionSets.append(conditionSet) | |
for subElement in ruleElement.findall(".sub"): | |
a = subElement.attrib["name"] | |
b = subElement.attrib["with"] | |
ruleObject.subs.append((a, b)) | |
rules.append(ruleObject) | |
self.documentObject.rules = rules | |
def _readConditionElements(self, parentElement, ruleName=None): | |
cds = [] | |
for conditionElement in parentElement.findall(".condition"): | |
cd = {} | |
cdMin = conditionElement.attrib.get("minimum") | |
if cdMin is not None: | |
cd["minimum"] = float(cdMin) | |
else: | |
# will allow these to be None, assume axis.minimum | |
cd["minimum"] = None | |
cdMax = conditionElement.attrib.get("maximum") | |
if cdMax is not None: | |
cd["maximum"] = float(cdMax) | |
else: | |
# will allow these to be None, assume axis.maximum | |
cd["maximum"] = None | |
cd["name"] = conditionElement.attrib.get("name") | |
# # test for things | |
if cd.get("minimum") is None and cd.get("maximum") is None: | |
raise DesignSpaceDocumentError( | |
"condition missing required minimum or maximum in rule" | |
+ (" '%s'" % ruleName if ruleName is not None else "") | |
) | |
cds.append(cd) | |
return cds | |
def readAxes(self): | |
# read the axes elements, including the warp map. | |
axesElement = self.root.find(".axes") | |
if axesElement is not None and "elidedfallbackname" in axesElement.attrib: | |
self.documentObject.elidedFallbackName = axesElement.attrib[ | |
"elidedfallbackname" | |
] | |
axisElements = self.root.findall(".axes/axis") | |
if not axisElements: | |
return | |
for axisElement in axisElements: | |
if ( | |
self.documentObject.formatTuple >= (5, 0) | |
and "values" in axisElement.attrib | |
): | |
axisObject = self.discreteAxisDescriptorClass() | |
axisObject.values = [ | |
float(s) for s in axisElement.attrib["values"].split(" ") | |
] | |
else: | |
axisObject = self.axisDescriptorClass() | |
axisObject.minimum = float(axisElement.attrib.get("minimum")) | |
axisObject.maximum = float(axisElement.attrib.get("maximum")) | |
axisObject.default = float(axisElement.attrib.get("default")) | |
axisObject.name = axisElement.attrib.get("name") | |
if axisElement.attrib.get("hidden", False): | |
axisObject.hidden = True | |
axisObject.tag = axisElement.attrib.get("tag") | |
for mapElement in axisElement.findall("map"): | |
a = float(mapElement.attrib["input"]) | |
b = float(mapElement.attrib["output"]) | |
axisObject.map.append((a, b)) | |
for labelNameElement in axisElement.findall("labelname"): | |
# Note: elementtree reads the "xml:lang" attribute name as | |
# '{http://www.w3.org/XML/1998/namespace}lang' | |
for key, lang in labelNameElement.items(): | |
if key == XML_LANG: | |
axisObject.labelNames[lang] = tostr(labelNameElement.text) | |
labelElement = axisElement.find(".labels") | |
if labelElement is not None: | |
if "ordering" in labelElement.attrib: | |
axisObject.axisOrdering = int(labelElement.attrib["ordering"]) | |
for label in labelElement.findall(".label"): | |
axisObject.axisLabels.append(self.readAxisLabel(label)) | |
self.documentObject.axes.append(axisObject) | |
self.axisDefaults[axisObject.name] = axisObject.default | |
self.documentObject.axisMappings = [] | |
for mappingsElement in self.root.findall(".axes/mappings"): | |
groupDescription = mappingsElement.attrib.get("description") | |
for mappingElement in mappingsElement.findall("mapping"): | |
description = mappingElement.attrib.get("description") | |
inputElement = mappingElement.find("input") | |
outputElement = mappingElement.find("output") | |
inputLoc = {} | |
outputLoc = {} | |
for dimElement in inputElement.findall(".dimension"): | |
name = dimElement.attrib["name"] | |
value = float(dimElement.attrib["xvalue"]) | |
inputLoc[name] = value | |
for dimElement in outputElement.findall(".dimension"): | |
name = dimElement.attrib["name"] | |
value = float(dimElement.attrib["xvalue"]) | |
outputLoc[name] = value | |
axisMappingObject = self.axisMappingDescriptorClass( | |
inputLocation=inputLoc, | |
outputLocation=outputLoc, | |
description=description, | |
groupDescription=groupDescription, | |
) | |
self.documentObject.axisMappings.append(axisMappingObject) | |
def readAxisLabel(self, element: ET.Element): | |
xml_attrs = { | |
"userminimum", | |
"uservalue", | |
"usermaximum", | |
"name", | |
"elidable", | |
"oldersibling", | |
"linkeduservalue", | |
} | |
unknown_attrs = set(element.attrib) - xml_attrs | |
if unknown_attrs: | |
raise DesignSpaceDocumentError( | |
f"label element contains unknown attributes: {', '.join(unknown_attrs)}" | |
) | |
name = element.get("name") | |
if name is None: | |
raise DesignSpaceDocumentError("label element must have a name attribute.") | |
valueStr = element.get("uservalue") | |
if valueStr is None: | |
raise DesignSpaceDocumentError( | |
"label element must have a uservalue attribute." | |
) | |
value = float(valueStr) | |
minimumStr = element.get("userminimum") | |
minimum = float(minimumStr) if minimumStr is not None else None | |
maximumStr = element.get("usermaximum") | |
maximum = float(maximumStr) if maximumStr is not None else None | |
linkedValueStr = element.get("linkeduservalue") | |
linkedValue = float(linkedValueStr) if linkedValueStr is not None else None | |
elidable = True if element.get("elidable") == "true" else False | |
olderSibling = True if element.get("oldersibling") == "true" else False | |
labelNames = { | |
lang: label_name.text or "" | |
for label_name in element.findall("labelname") | |
for attr, lang in label_name.items() | |
if attr == XML_LANG | |
# Note: elementtree reads the "xml:lang" attribute name as | |
# '{http://www.w3.org/XML/1998/namespace}lang' | |
} | |
return self.axisLabelDescriptorClass( | |
name=name, | |
userValue=value, | |
userMinimum=minimum, | |
userMaximum=maximum, | |
elidable=elidable, | |
olderSibling=olderSibling, | |
linkedUserValue=linkedValue, | |
labelNames=labelNames, | |
) | |
def readLabels(self): | |
if self.documentObject.formatTuple < (5, 0): | |
return | |
xml_attrs = {"name", "elidable", "oldersibling"} | |
for labelElement in self.root.findall(".labels/label"): | |
unknown_attrs = set(labelElement.attrib) - xml_attrs | |
if unknown_attrs: | |
raise DesignSpaceDocumentError( | |
f"Label element contains unknown attributes: {', '.join(unknown_attrs)}" | |
) | |
name = labelElement.get("name") | |
if name is None: | |
raise DesignSpaceDocumentError( | |
"label element must have a name attribute." | |
) | |
designLocation, userLocation = self.locationFromElement(labelElement) | |
if designLocation: | |
raise DesignSpaceDocumentError( | |
f'<label> element "{name}" must only have user locations (using uservalue="").' | |
) | |
elidable = True if labelElement.get("elidable") == "true" else False | |
olderSibling = True if labelElement.get("oldersibling") == "true" else False | |
labelNames = { | |
lang: label_name.text or "" | |
for label_name in labelElement.findall("labelname") | |
for attr, lang in label_name.items() | |
if attr == XML_LANG | |
# Note: elementtree reads the "xml:lang" attribute name as | |
# '{http://www.w3.org/XML/1998/namespace}lang' | |
} | |
locationLabel = self.locationLabelDescriptorClass( | |
name=name, | |
userLocation=userLocation, | |
elidable=elidable, | |
olderSibling=olderSibling, | |
labelNames=labelNames, | |
) | |
self.documentObject.locationLabels.append(locationLabel) | |
def readVariableFonts(self): | |
if self.documentObject.formatTuple < (5, 0): | |
return | |
xml_attrs = {"name", "filename"} | |
for variableFontElement in self.root.findall(".variable-fonts/variable-font"): | |
unknown_attrs = set(variableFontElement.attrib) - xml_attrs | |
if unknown_attrs: | |
raise DesignSpaceDocumentError( | |
f"variable-font element contains unknown attributes: {', '.join(unknown_attrs)}" | |
) | |
name = variableFontElement.get("name") | |
if name is None: | |
raise DesignSpaceDocumentError( | |
"variable-font element must have a name attribute." | |
) | |
filename = variableFontElement.get("filename") | |
axisSubsetsElement = variableFontElement.find(".axis-subsets") | |
if axisSubsetsElement is None: | |
raise DesignSpaceDocumentError( | |
"variable-font element must contain an axis-subsets element." | |
) | |
axisSubsets = [] | |
for axisSubset in axisSubsetsElement.iterfind(".axis-subset"): | |
axisSubsets.append(self.readAxisSubset(axisSubset)) | |
lib = None | |
libElement = variableFontElement.find(".lib") | |
if libElement is not None: | |
lib = plistlib.fromtree(libElement[0]) | |
variableFont = self.variableFontsDescriptorClass( | |
name=name, | |
filename=filename, | |
axisSubsets=axisSubsets, | |
lib=lib, | |
) | |
self.documentObject.variableFonts.append(variableFont) | |
def readAxisSubset(self, element: ET.Element): | |
if "uservalue" in element.attrib: | |
xml_attrs = {"name", "uservalue"} | |
unknown_attrs = set(element.attrib) - xml_attrs | |
if unknown_attrs: | |
raise DesignSpaceDocumentError( | |
f"axis-subset element contains unknown attributes: {', '.join(unknown_attrs)}" | |
) | |
name = element.get("name") | |
if name is None: | |
raise DesignSpaceDocumentError( | |
"axis-subset element must have a name attribute." | |
) | |
userValueStr = element.get("uservalue") | |
if userValueStr is None: | |
raise DesignSpaceDocumentError( | |
"The axis-subset element for a discrete subset must have a uservalue attribute." | |
) | |
userValue = float(userValueStr) | |
return self.valueAxisSubsetDescriptorClass(name=name, userValue=userValue) | |
else: | |
xml_attrs = {"name", "userminimum", "userdefault", "usermaximum"} | |
unknown_attrs = set(element.attrib) - xml_attrs | |
if unknown_attrs: | |
raise DesignSpaceDocumentError( | |
f"axis-subset element contains unknown attributes: {', '.join(unknown_attrs)}" | |
) | |
name = element.get("name") | |
if name is None: | |
raise DesignSpaceDocumentError( | |
"axis-subset element must have a name attribute." | |
) | |
userMinimum = element.get("userminimum") | |
userDefault = element.get("userdefault") | |
userMaximum = element.get("usermaximum") | |
if ( | |
userMinimum is not None | |
and userDefault is not None | |
and userMaximum is not None | |
): | |
return self.rangeAxisSubsetDescriptorClass( | |
name=name, | |
userMinimum=float(userMinimum), | |
userDefault=float(userDefault), | |
userMaximum=float(userMaximum), | |
) | |
if all(v is None for v in (userMinimum, userDefault, userMaximum)): | |
return self.rangeAxisSubsetDescriptorClass(name=name) | |
raise DesignSpaceDocumentError( | |
"axis-subset element must have min/max/default values or none at all." | |
) | |
def readSources(self): | |
for sourceCount, sourceElement in enumerate( | |
self.root.findall(".sources/source") | |
): | |
filename = sourceElement.attrib.get("filename") | |
if filename is not None and self.path is not None: | |
sourcePath = os.path.abspath( | |
os.path.join(os.path.dirname(self.path), filename) | |
) | |
else: | |
sourcePath = None | |
sourceName = sourceElement.attrib.get("name") | |
if sourceName is None: | |
# add a temporary source name | |
sourceName = "temp_master.%d" % (sourceCount) | |
sourceObject = self.sourceDescriptorClass() | |
sourceObject.path = sourcePath # absolute path to the ufo source | |
sourceObject.filename = filename # path as it is stored in the document | |
sourceObject.name = sourceName | |
familyName = sourceElement.attrib.get("familyname") | |
if familyName is not None: | |
sourceObject.familyName = familyName | |
styleName = sourceElement.attrib.get("stylename") | |
if styleName is not None: | |
sourceObject.styleName = styleName | |
for familyNameElement in sourceElement.findall("familyname"): | |
for key, lang in familyNameElement.items(): | |
if key == XML_LANG: | |
familyName = familyNameElement.text | |
sourceObject.setFamilyName(familyName, lang) | |
designLocation, userLocation = self.locationFromElement(sourceElement) | |
if userLocation: | |
raise DesignSpaceDocumentError( | |
f'<source> element "{sourceName}" must only have design locations (using xvalue="").' | |
) | |
sourceObject.location = designLocation | |
layerName = sourceElement.attrib.get("layer") | |
if layerName is not None: | |
sourceObject.layerName = layerName | |
for libElement in sourceElement.findall(".lib"): | |
if libElement.attrib.get("copy") == "1": | |
sourceObject.copyLib = True | |
for groupsElement in sourceElement.findall(".groups"): | |
if groupsElement.attrib.get("copy") == "1": | |
sourceObject.copyGroups = True | |
for infoElement in sourceElement.findall(".info"): | |
if infoElement.attrib.get("copy") == "1": | |
sourceObject.copyInfo = True | |
if infoElement.attrib.get("mute") == "1": | |
sourceObject.muteInfo = True | |
for featuresElement in sourceElement.findall(".features"): | |
if featuresElement.attrib.get("copy") == "1": | |
sourceObject.copyFeatures = True | |
for glyphElement in sourceElement.findall(".glyph"): | |
glyphName = glyphElement.attrib.get("name") | |
if glyphName is None: | |
continue | |
if glyphElement.attrib.get("mute") == "1": | |
sourceObject.mutedGlyphNames.append(glyphName) | |
for kerningElement in sourceElement.findall(".kerning"): | |
if kerningElement.attrib.get("mute") == "1": | |
sourceObject.muteKerning = True | |
self.documentObject.sources.append(sourceObject) | |
def locationFromElement(self, element): | |
"""Read a nested ``<location>`` element inside the given ``element``. | |
.. versionchanged:: 5.0 | |
Return a tuple of (designLocation, userLocation) | |
""" | |
elementLocation = (None, None) | |
for locationElement in element.findall(".location"): | |
elementLocation = self.readLocationElement(locationElement) | |
break | |
return elementLocation | |
def readLocationElement(self, locationElement): | |
"""Read a ``<location>`` element. | |
.. versionchanged:: 5.0 | |
Return a tuple of (designLocation, userLocation) | |
""" | |
if self._strictAxisNames and not self.documentObject.axes: | |
raise DesignSpaceDocumentError("No axes defined") | |
userLoc = {} | |
designLoc = {} | |
for dimensionElement in locationElement.findall(".dimension"): | |
dimName = dimensionElement.attrib.get("name") | |
if self._strictAxisNames and dimName not in self.axisDefaults: | |
# In case the document contains no axis definitions, | |
self.log.warning('Location with undefined axis: "%s".', dimName) | |
continue | |
userValue = xValue = yValue = None | |
try: | |
userValue = dimensionElement.attrib.get("uservalue") | |
if userValue is not None: | |
userValue = float(userValue) | |
except ValueError: | |
self.log.warning( | |
"ValueError in readLocation userValue %3.3f", userValue | |
) | |
try: | |
xValue = dimensionElement.attrib.get("xvalue") | |
if xValue is not None: | |
xValue = float(xValue) | |
except ValueError: | |
self.log.warning("ValueError in readLocation xValue %3.3f", xValue) | |
try: | |
yValue = dimensionElement.attrib.get("yvalue") | |
if yValue is not None: | |
yValue = float(yValue) | |
except ValueError: | |
self.log.warning("ValueError in readLocation yValue %3.3f", yValue) | |
if userValue is None == xValue is None: | |
raise DesignSpaceDocumentError( | |
f'Exactly one of uservalue="" or xvalue="" must be provided for location dimension "{dimName}"' | |
) | |
if yValue is not None: | |
if xValue is None: | |
raise DesignSpaceDocumentError( | |
f'Missing xvalue="" for the location dimension "{dimName}"" with yvalue="{yValue}"' | |
) | |
designLoc[dimName] = (xValue, yValue) | |
elif xValue is not None: | |
designLoc[dimName] = xValue | |
else: | |
userLoc[dimName] = userValue | |
return designLoc, userLoc | |
def readInstances(self, makeGlyphs=True, makeKerning=True, makeInfo=True): | |
instanceElements = self.root.findall(".instances/instance") | |
for instanceElement in instanceElements: | |
self._readSingleInstanceElement( | |
instanceElement, | |
makeGlyphs=makeGlyphs, | |
makeKerning=makeKerning, | |
makeInfo=makeInfo, | |
) | |
def _readSingleInstanceElement( | |
self, instanceElement, makeGlyphs=True, makeKerning=True, makeInfo=True | |
): | |
filename = instanceElement.attrib.get("filename") | |
if filename is not None and self.documentObject.path is not None: | |
instancePath = os.path.join( | |
os.path.dirname(self.documentObject.path), filename | |
) | |
else: | |
instancePath = None | |
instanceObject = self.instanceDescriptorClass() | |
instanceObject.path = instancePath # absolute path to the instance | |
instanceObject.filename = filename # path as it is stored in the document | |
name = instanceElement.attrib.get("name") | |
if name is not None: | |
instanceObject.name = name | |
familyname = instanceElement.attrib.get("familyname") | |
if familyname is not None: | |
instanceObject.familyName = familyname | |
stylename = instanceElement.attrib.get("stylename") | |
if stylename is not None: | |
instanceObject.styleName = stylename | |
postScriptFontName = instanceElement.attrib.get("postscriptfontname") | |
if postScriptFontName is not None: | |
instanceObject.postScriptFontName = postScriptFontName | |
styleMapFamilyName = instanceElement.attrib.get("stylemapfamilyname") | |
if styleMapFamilyName is not None: | |
instanceObject.styleMapFamilyName = styleMapFamilyName | |
styleMapStyleName = instanceElement.attrib.get("stylemapstylename") | |
if styleMapStyleName is not None: | |
instanceObject.styleMapStyleName = styleMapStyleName | |
# read localised names | |
for styleNameElement in instanceElement.findall("stylename"): | |
for key, lang in styleNameElement.items(): | |
if key == XML_LANG: | |
styleName = styleNameElement.text | |
instanceObject.setStyleName(styleName, lang) | |
for familyNameElement in instanceElement.findall("familyname"): | |
for key, lang in familyNameElement.items(): | |
if key == XML_LANG: | |
familyName = familyNameElement.text | |
instanceObject.setFamilyName(familyName, lang) | |
for styleMapStyleNameElement in instanceElement.findall("stylemapstylename"): | |
for key, lang in styleMapStyleNameElement.items(): | |
if key == XML_LANG: | |
styleMapStyleName = styleMapStyleNameElement.text | |
instanceObject.setStyleMapStyleName(styleMapStyleName, lang) | |
for styleMapFamilyNameElement in instanceElement.findall("stylemapfamilyname"): | |
for key, lang in styleMapFamilyNameElement.items(): | |
if key == XML_LANG: | |
styleMapFamilyName = styleMapFamilyNameElement.text | |
instanceObject.setStyleMapFamilyName(styleMapFamilyName, lang) | |
designLocation, userLocation = self.locationFromElement(instanceElement) | |
locationLabel = instanceElement.attrib.get("location") | |
if (designLocation or userLocation) and locationLabel is not None: | |
raise DesignSpaceDocumentError( | |
'instance element must have at most one of the location="..." attribute or the nested location element' | |
) | |
instanceObject.locationLabel = locationLabel | |
instanceObject.userLocation = userLocation or {} | |
instanceObject.designLocation = designLocation or {} | |
for glyphElement in instanceElement.findall(".glyphs/glyph"): | |
self.readGlyphElement(glyphElement, instanceObject) | |
for infoElement in instanceElement.findall("info"): | |
self.readInfoElement(infoElement, instanceObject) | |
for libElement in instanceElement.findall("lib"): | |
self.readLibElement(libElement, instanceObject) | |
self.documentObject.instances.append(instanceObject) | |
def readLibElement(self, libElement, instanceObject): | |
"""Read the lib element for the given instance.""" | |
instanceObject.lib = plistlib.fromtree(libElement[0]) | |
def readInfoElement(self, infoElement, instanceObject): | |
"""Read the info element.""" | |
instanceObject.info = True | |
def readGlyphElement(self, glyphElement, instanceObject): | |
""" | |
Read the glyph element, which could look like either one of these: | |
.. code-block:: xml | |
<glyph name="b" unicode="0x62"/> | |
<glyph name="b"/> | |
<glyph name="b"> | |
<master location="location-token-bbb" source="master-token-aaa2"/> | |
<master glyphname="b.alt1" location="location-token-ccc" source="master-token-aaa3"/> | |
<note> | |
This is an instance from an anisotropic interpolation. | |
</note> | |
</glyph> | |
""" | |
glyphData = {} | |
glyphName = glyphElement.attrib.get("name") | |
if glyphName is None: | |
raise DesignSpaceDocumentError("Glyph object without name attribute") | |
mute = glyphElement.attrib.get("mute") | |
if mute == "1": | |
glyphData["mute"] = True | |
# unicode | |
unicodes = glyphElement.attrib.get("unicode") | |
if unicodes is not None: | |
try: | |
unicodes = [int(u, 16) for u in unicodes.split(" ")] | |
glyphData["unicodes"] = unicodes | |
except ValueError: | |
raise DesignSpaceDocumentError( | |
"unicode values %s are not integers" % unicodes | |
) | |
for noteElement in glyphElement.findall(".note"): | |
glyphData["note"] = noteElement.text | |
break | |
designLocation, userLocation = self.locationFromElement(glyphElement) | |
if userLocation: | |
raise DesignSpaceDocumentError( | |
f'<glyph> element "{glyphName}" must only have design locations (using xvalue="").' | |
) | |
if designLocation is not None: | |
glyphData["instanceLocation"] = designLocation | |
glyphSources = None | |
for masterElement in glyphElement.findall(".masters/master"): | |
fontSourceName = masterElement.attrib.get("source") | |
designLocation, userLocation = self.locationFromElement(masterElement) | |
if userLocation: | |
raise DesignSpaceDocumentError( | |
f'<master> element "{fontSourceName}" must only have design locations (using xvalue="").' | |
) | |
masterGlyphName = masterElement.attrib.get("glyphname") | |
if masterGlyphName is None: | |
# if we don't read a glyphname, use the one we have | |
masterGlyphName = glyphName | |
d = dict( | |
font=fontSourceName, location=designLocation, glyphName=masterGlyphName | |
) | |
if glyphSources is None: | |
glyphSources = [] | |
glyphSources.append(d) | |
if glyphSources is not None: | |
glyphData["masters"] = glyphSources | |
instanceObject.glyphs[glyphName] = glyphData | |
def readLib(self): | |
"""Read the lib element for the whole document.""" | |
for libElement in self.root.findall(".lib"): | |
self.documentObject.lib = plistlib.fromtree(libElement[0]) | |
class DesignSpaceDocument(LogMixin, AsDictMixin): | |
"""The DesignSpaceDocument object can read and write ``.designspace`` data. | |
It imports the axes, sources, variable fonts and instances to very basic | |
**descriptor** objects that store the data in attributes. Data is added to | |
the document by creating such descriptor objects, filling them with data | |
and then adding them to the document. This makes it easy to integrate this | |
object in different contexts. | |
The **DesignSpaceDocument** object can be subclassed to work with | |
different objects, as long as they have the same attributes. Reader and | |
Writer objects can be subclassed as well. | |
**Note:** Python attribute names are usually camelCased, the | |
corresponding `XML <document-xml-structure>`_ attributes are usually | |
all lowercase. | |
.. code:: python | |
from fontTools.designspaceLib import DesignSpaceDocument | |
doc = DesignSpaceDocument.fromfile("some/path/to/my.designspace") | |
doc.formatVersion | |
doc.elidedFallbackName | |
doc.axes | |
doc.axisMappings | |
doc.locationLabels | |
doc.rules | |
doc.rulesProcessingLast | |
doc.sources | |
doc.variableFonts | |
doc.instances | |
doc.lib | |
""" | |
def __init__(self, readerClass=None, writerClass=None): | |
self.path = None | |
"""String, optional. When the document is read from the disk, this is | |
the full path that was given to :meth:`read` or :meth:`fromfile`. | |
""" | |
self.filename = None | |
"""String, optional. When the document is read from the disk, this is | |
its original file name, i.e. the last part of its path. | |
When the document is produced by a Python script and still only exists | |
in memory, the producing script can write here an indication of a | |
possible "good" filename, in case one wants to save the file somewhere. | |
""" | |
self.formatVersion: Optional[str] = None | |
"""Format version for this document, as a string. E.g. "4.0" """ | |
self.elidedFallbackName: Optional[str] = None | |
"""STAT Style Attributes Header field ``elidedFallbackNameID``. | |
See: `OTSpec STAT Style Attributes Header <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#style-attributes-header>`_ | |
.. versionadded:: 5.0 | |
""" | |
self.axes: List[Union[AxisDescriptor, DiscreteAxisDescriptor]] = [] | |
"""List of this document's axes.""" | |
self.axisMappings: List[AxisMappingDescriptor] = [] | |
"""List of this document's axis mappings.""" | |
self.locationLabels: List[LocationLabelDescriptor] = [] | |
"""List of this document's STAT format 4 labels. | |
.. versionadded:: 5.0""" | |
self.rules: List[RuleDescriptor] = [] | |
"""List of this document's rules.""" | |
self.rulesProcessingLast: bool = False | |
"""This flag indicates whether the substitution rules should be applied | |
before or after other glyph substitution features. | |
- False: before | |
- True: after. | |
Default is False. For new projects, you probably want True. See | |
the following issues for more information: | |
`fontTools#1371 <https://github.com/fonttools/fonttools/issues/1371#issuecomment-590214572>`__ | |
`fontTools#2050 <https://github.com/fonttools/fonttools/issues/2050#issuecomment-678691020>`__ | |
If you want to use a different feature altogether, e.g. ``calt``, | |
use the lib key ``com.github.fonttools.varLib.featureVarsFeatureTag`` | |
.. code:: xml | |
<lib> | |
<dict> | |
<key>com.github.fonttools.varLib.featureVarsFeatureTag</key> | |
<string>calt</string> | |
</dict> | |
</lib> | |
""" | |
self.sources: List[SourceDescriptor] = [] | |
"""List of this document's sources.""" | |
self.variableFonts: List[VariableFontDescriptor] = [] | |
"""List of this document's variable fonts. | |
.. versionadded:: 5.0""" | |
self.instances: List[InstanceDescriptor] = [] | |
"""List of this document's instances.""" | |
self.lib: Dict = {} | |
"""User defined, custom data associated with the whole document. | |
Use reverse-DNS notation to identify your own data. | |
Respect the data stored by others. | |
""" | |
self.default: Optional[str] = None | |
"""Name of the default master. | |
This attribute is updated by the :meth:`findDefault` | |
""" | |
if readerClass is not None: | |
self.readerClass = readerClass | |
else: | |
self.readerClass = BaseDocReader | |
if writerClass is not None: | |
self.writerClass = writerClass | |
else: | |
self.writerClass = BaseDocWriter | |
def fromfile(cls, path, readerClass=None, writerClass=None): | |
"""Read a designspace file from ``path`` and return a new instance of | |
:class:. | |
""" | |
self = cls(readerClass=readerClass, writerClass=writerClass) | |
self.read(path) | |
return self | |
def fromstring(cls, string, readerClass=None, writerClass=None): | |
self = cls(readerClass=readerClass, writerClass=writerClass) | |
reader = self.readerClass.fromstring(string, self) | |
reader.read() | |
if self.sources: | |
self.findDefault() | |
return self | |
def tostring(self, encoding=None): | |
"""Returns the designspace as a string. Default encoding ``utf-8``.""" | |
if encoding is str or (encoding is not None and encoding.lower() == "unicode"): | |
f = StringIO() | |
xml_declaration = False | |
elif encoding is None or encoding == "utf-8": | |
f = BytesIO() | |
encoding = "UTF-8" | |
xml_declaration = True | |
else: | |
raise ValueError("unsupported encoding: '%s'" % encoding) | |
writer = self.writerClass(f, self) | |
writer.write(encoding=encoding, xml_declaration=xml_declaration) | |
return f.getvalue() | |
def read(self, path): | |
"""Read a designspace file from ``path`` and populates the fields of | |
``self`` with the data. | |
""" | |
if hasattr(path, "__fspath__"): # support os.PathLike objects | |
path = path.__fspath__() | |
self.path = path | |
self.filename = os.path.basename(path) | |
reader = self.readerClass(path, self) | |
reader.read() | |
if self.sources: | |
self.findDefault() | |
def write(self, path): | |
"""Write this designspace to ``path``.""" | |
if hasattr(path, "__fspath__"): # support os.PathLike objects | |
path = path.__fspath__() | |
self.path = path | |
self.filename = os.path.basename(path) | |
self.updatePaths() | |
writer = self.writerClass(path, self) | |
writer.write() | |
def _posixRelativePath(self, otherPath): | |
relative = os.path.relpath(otherPath, os.path.dirname(self.path)) | |
return posix(relative) | |
def updatePaths(self): | |
""" | |
Right before we save we need to identify and respond to the following situations: | |
In each descriptor, we have to do the right thing for the filename attribute. | |
:: | |
case 1. | |
descriptor.filename == None | |
descriptor.path == None | |
-- action: | |
write as is, descriptors will not have a filename attr. | |
useless, but no reason to interfere. | |
case 2. | |
descriptor.filename == "../something" | |
descriptor.path == None | |
-- action: | |
write as is. The filename attr should not be touched. | |
case 3. | |
descriptor.filename == None | |
descriptor.path == "~/absolute/path/there" | |
-- action: | |
calculate the relative path for filename. | |
We're not overwriting some other value for filename, it should be fine | |
case 4. | |
descriptor.filename == '../somewhere' | |
descriptor.path == "~/absolute/path/there" | |
-- action: | |
there is a conflict between the given filename, and the path. | |
So we know where the file is relative to the document. | |
Can't guess why they're different, we just choose for path to be correct and update filename. | |
""" | |
assert self.path is not None | |
for descriptor in self.sources + self.instances: | |
if descriptor.path is not None: | |
# case 3 and 4: filename gets updated and relativized | |
descriptor.filename = self._posixRelativePath(descriptor.path) | |
def addSource(self, sourceDescriptor: SourceDescriptor): | |
"""Add the given ``sourceDescriptor`` to ``doc.sources``.""" | |
self.sources.append(sourceDescriptor) | |
def addSourceDescriptor(self, **kwargs): | |
"""Instantiate a new :class:`SourceDescriptor` using the given | |
``kwargs`` and add it to ``doc.sources``. | |
""" | |
source = self.writerClass.sourceDescriptorClass(**kwargs) | |
self.addSource(source) | |
return source | |
def addInstance(self, instanceDescriptor: InstanceDescriptor): | |
"""Add the given ``instanceDescriptor`` to :attr:`instances`.""" | |
self.instances.append(instanceDescriptor) | |
def addInstanceDescriptor(self, **kwargs): | |
"""Instantiate a new :class:`InstanceDescriptor` using the given | |
``kwargs`` and add it to :attr:`instances`. | |
""" | |
instance = self.writerClass.instanceDescriptorClass(**kwargs) | |
self.addInstance(instance) | |
return instance | |
def addAxis(self, axisDescriptor: Union[AxisDescriptor, DiscreteAxisDescriptor]): | |
"""Add the given ``axisDescriptor`` to :attr:`axes`.""" | |
self.axes.append(axisDescriptor) | |
def addAxisDescriptor(self, **kwargs): | |
"""Instantiate a new :class:`AxisDescriptor` using the given | |
``kwargs`` and add it to :attr:`axes`. | |
The axis will be and instance of :class:`DiscreteAxisDescriptor` if | |
the ``kwargs`` provide a ``value``, or a :class:`AxisDescriptor` otherwise. | |
""" | |
if "values" in kwargs: | |
axis = self.writerClass.discreteAxisDescriptorClass(**kwargs) | |
else: | |
axis = self.writerClass.axisDescriptorClass(**kwargs) | |
self.addAxis(axis) | |
return axis | |
def addAxisMapping(self, axisMappingDescriptor: AxisMappingDescriptor): | |
"""Add the given ``axisMappingDescriptor`` to :attr:`axisMappings`.""" | |
self.axisMappings.append(axisMappingDescriptor) | |
def addAxisMappingDescriptor(self, **kwargs): | |
"""Instantiate a new :class:`AxisMappingDescriptor` using the given | |
``kwargs`` and add it to :attr:`rules`. | |
""" | |
axisMapping = self.writerClass.axisMappingDescriptorClass(**kwargs) | |
self.addAxisMapping(axisMapping) | |
return axisMapping | |
def addRule(self, ruleDescriptor: RuleDescriptor): | |
"""Add the given ``ruleDescriptor`` to :attr:`rules`.""" | |
self.rules.append(ruleDescriptor) | |
def addRuleDescriptor(self, **kwargs): | |
"""Instantiate a new :class:`RuleDescriptor` using the given | |
``kwargs`` and add it to :attr:`rules`. | |
""" | |
rule = self.writerClass.ruleDescriptorClass(**kwargs) | |
self.addRule(rule) | |
return rule | |
def addVariableFont(self, variableFontDescriptor: VariableFontDescriptor): | |
"""Add the given ``variableFontDescriptor`` to :attr:`variableFonts`. | |
.. versionadded:: 5.0 | |
""" | |
self.variableFonts.append(variableFontDescriptor) | |
def addVariableFontDescriptor(self, **kwargs): | |
"""Instantiate a new :class:`VariableFontDescriptor` using the given | |
``kwargs`` and add it to :attr:`variableFonts`. | |
.. versionadded:: 5.0 | |
""" | |
variableFont = self.writerClass.variableFontDescriptorClass(**kwargs) | |
self.addVariableFont(variableFont) | |
return variableFont | |
def addLocationLabel(self, locationLabelDescriptor: LocationLabelDescriptor): | |
"""Add the given ``locationLabelDescriptor`` to :attr:`locationLabels`. | |
.. versionadded:: 5.0 | |
""" | |
self.locationLabels.append(locationLabelDescriptor) | |
def addLocationLabelDescriptor(self, **kwargs): | |
"""Instantiate a new :class:`LocationLabelDescriptor` using the given | |
``kwargs`` and add it to :attr:`locationLabels`. | |
.. versionadded:: 5.0 | |
""" | |
locationLabel = self.writerClass.locationLabelDescriptorClass(**kwargs) | |
self.addLocationLabel(locationLabel) | |
return locationLabel | |
def newDefaultLocation(self): | |
"""Return a dict with the default location in design space coordinates.""" | |
# Without OrderedDict, output XML would be non-deterministic. | |
# https://github.com/LettError/designSpaceDocument/issues/10 | |
loc = collections.OrderedDict() | |
for axisDescriptor in self.axes: | |
loc[axisDescriptor.name] = axisDescriptor.map_forward( | |
axisDescriptor.default | |
) | |
return loc | |
def labelForUserLocation( | |
self, userLocation: SimpleLocationDict | |
) -> Optional[LocationLabelDescriptor]: | |
"""Return the :class:`LocationLabel` that matches the given | |
``userLocation``, or ``None`` if no such label exists. | |
.. versionadded:: 5.0 | |
""" | |
return next( | |
( | |
label | |
for label in self.locationLabels | |
if label.userLocation == userLocation | |
), | |
None, | |
) | |
def updateFilenameFromPath(self, masters=True, instances=True, force=False): | |
"""Set a descriptor filename attr from the path and this document path. | |
If the filename attribute is not None: skip it. | |
""" | |
if masters: | |
for descriptor in self.sources: | |
if descriptor.filename is not None and not force: | |
continue | |
if self.path is not None: | |
descriptor.filename = self._posixRelativePath(descriptor.path) | |
if instances: | |
for descriptor in self.instances: | |
if descriptor.filename is not None and not force: | |
continue | |
if self.path is not None: | |
descriptor.filename = self._posixRelativePath(descriptor.path) | |
def newAxisDescriptor(self): | |
"""Ask the writer class to make us a new axisDescriptor.""" | |
return self.writerClass.getAxisDecriptor() | |
def newSourceDescriptor(self): | |
"""Ask the writer class to make us a new sourceDescriptor.""" | |
return self.writerClass.getSourceDescriptor() | |
def newInstanceDescriptor(self): | |
"""Ask the writer class to make us a new instanceDescriptor.""" | |
return self.writerClass.getInstanceDescriptor() | |
def getAxisOrder(self): | |
"""Return a list of axis names, in the same order as defined in the document.""" | |
names = [] | |
for axisDescriptor in self.axes: | |
names.append(axisDescriptor.name) | |
return names | |
def getAxis(self, name: str) -> AxisDescriptor | DiscreteAxisDescriptor | None: | |
"""Return the axis with the given ``name``, or ``None`` if no such axis exists.""" | |
return next((axis for axis in self.axes if axis.name == name), None) | |
def getAxisByTag(self, tag: str) -> AxisDescriptor | DiscreteAxisDescriptor | None: | |
"""Return the axis with the given ``tag``, or ``None`` if no such axis exists.""" | |
return next((axis for axis in self.axes if axis.tag == tag), None) | |
def getLocationLabel(self, name: str) -> Optional[LocationLabelDescriptor]: | |
"""Return the top-level location label with the given ``name``, or | |
``None`` if no such label exists. | |
.. versionadded:: 5.0 | |
""" | |
for label in self.locationLabels: | |
if label.name == name: | |
return label | |
return None | |
def map_forward(self, userLocation: SimpleLocationDict) -> SimpleLocationDict: | |
"""Map a user location to a design location. | |
Assume that missing coordinates are at the default location for that axis. | |
Note: the output won't be anisotropic, only the xvalue is set. | |
.. versionadded:: 5.0 | |
""" | |
return { | |
axis.name: axis.map_forward(userLocation.get(axis.name, axis.default)) | |
for axis in self.axes | |
} | |
def map_backward( | |
self, designLocation: AnisotropicLocationDict | |
) -> SimpleLocationDict: | |
"""Map a design location to a user location. | |
Assume that missing coordinates are at the default location for that axis. | |
When the input has anisotropic locations, only the xvalue is used. | |
.. versionadded:: 5.0 | |
""" | |
return { | |
axis.name: ( | |
axis.map_backward(designLocation[axis.name]) | |
if axis.name in designLocation | |
else axis.default | |
) | |
for axis in self.axes | |
} | |
def findDefault(self): | |
"""Set and return SourceDescriptor at the default location or None. | |
The default location is the set of all `default` values in user space | |
of all axes. | |
This function updates the document's :attr:`default` value. | |
.. versionchanged:: 5.0 | |
Allow the default source to not specify some of the axis values, and | |
they are assumed to be the default. | |
See :meth:`SourceDescriptor.getFullDesignLocation()` | |
""" | |
self.default = None | |
# Convert the default location from user space to design space before comparing | |
# it against the SourceDescriptor locations (always in design space). | |
defaultDesignLocation = self.newDefaultLocation() | |
for sourceDescriptor in self.sources: | |
if sourceDescriptor.getFullDesignLocation(self) == defaultDesignLocation: | |
self.default = sourceDescriptor | |
return sourceDescriptor | |
return None | |
def normalizeLocation(self, location): | |
"""Return a dict with normalized axis values.""" | |
from fontTools.varLib.models import normalizeValue | |
new = {} | |
for axis in self.axes: | |
if axis.name not in location: | |
# skipping this dimension it seems | |
continue | |
value = location[axis.name] | |
# 'anisotropic' location, take first coord only | |
if isinstance(value, tuple): | |
value = value[0] | |
triple = [ | |
axis.map_forward(v) for v in (axis.minimum, axis.default, axis.maximum) | |
] | |
new[axis.name] = normalizeValue(value, triple) | |
return new | |
def normalize(self): | |
""" | |
Normalise the geometry of this designspace: | |
- scale all the locations of all masters and instances to the -1 - 0 - 1 value. | |
- we need the axis data to do the scaling, so we do those last. | |
""" | |
# masters | |
for item in self.sources: | |
item.location = self.normalizeLocation(item.location) | |
# instances | |
for item in self.instances: | |
# glyph masters for this instance | |
for _, glyphData in item.glyphs.items(): | |
glyphData["instanceLocation"] = self.normalizeLocation( | |
glyphData["instanceLocation"] | |
) | |
for glyphMaster in glyphData["masters"]: | |
glyphMaster["location"] = self.normalizeLocation( | |
glyphMaster["location"] | |
) | |
item.location = self.normalizeLocation(item.location) | |
# the axes | |
for axis in self.axes: | |
# scale the map first | |
newMap = [] | |
for inputValue, outputValue in axis.map: | |
newOutputValue = self.normalizeLocation({axis.name: outputValue}).get( | |
axis.name | |
) | |
newMap.append((inputValue, newOutputValue)) | |
if newMap: | |
axis.map = newMap | |
# finally the axis values | |
minimum = self.normalizeLocation({axis.name: axis.minimum}).get(axis.name) | |
maximum = self.normalizeLocation({axis.name: axis.maximum}).get(axis.name) | |
default = self.normalizeLocation({axis.name: axis.default}).get(axis.name) | |
# and set them in the axis.minimum | |
axis.minimum = minimum | |
axis.maximum = maximum | |
axis.default = default | |
# now the rules | |
for rule in self.rules: | |
newConditionSets = [] | |
for conditions in rule.conditionSets: | |
newConditions = [] | |
for cond in conditions: | |
if cond.get("minimum") is not None: | |
minimum = self.normalizeLocation( | |
{cond["name"]: cond["minimum"]} | |
).get(cond["name"]) | |
else: | |
minimum = None | |
if cond.get("maximum") is not None: | |
maximum = self.normalizeLocation( | |
{cond["name"]: cond["maximum"]} | |
).get(cond["name"]) | |
else: | |
maximum = None | |
newConditions.append( | |
dict(name=cond["name"], minimum=minimum, maximum=maximum) | |
) | |
newConditionSets.append(newConditions) | |
rule.conditionSets = newConditionSets | |
def loadSourceFonts(self, opener, **kwargs): | |
"""Ensure SourceDescriptor.font attributes are loaded, and return list of fonts. | |
Takes a callable which initializes a new font object (e.g. TTFont, or | |
defcon.Font, etc.) from the SourceDescriptor.path, and sets the | |
SourceDescriptor.font attribute. | |
If the font attribute is already not None, it is not loaded again. | |
Fonts with the same path are only loaded once and shared among SourceDescriptors. | |
For example, to load UFO sources using defcon: | |
designspace = DesignSpaceDocument.fromfile("path/to/my.designspace") | |
designspace.loadSourceFonts(defcon.Font) | |
Or to load masters as FontTools binary fonts, including extra options: | |
designspace.loadSourceFonts(ttLib.TTFont, recalcBBoxes=False) | |
Args: | |
opener (Callable): takes one required positional argument, the source.path, | |
and an optional list of keyword arguments, and returns a new font object | |
loaded from the path. | |
**kwargs: extra options passed on to the opener function. | |
Returns: | |
List of font objects in the order they appear in the sources list. | |
""" | |
# we load fonts with the same source.path only once | |
loaded = {} | |
fonts = [] | |
for source in self.sources: | |
if source.font is not None: # font already loaded | |
fonts.append(source.font) | |
continue | |
if source.path in loaded: | |
source.font = loaded[source.path] | |
else: | |
if source.path is None: | |
raise DesignSpaceDocumentError( | |
"Designspace source '%s' has no 'path' attribute" | |
% (source.name or "<Unknown>") | |
) | |
source.font = opener(source.path, **kwargs) | |
loaded[source.path] = source.font | |
fonts.append(source.font) | |
return fonts | |
def formatTuple(self): | |
"""Return the formatVersion as a tuple of (major, minor). | |
.. versionadded:: 5.0 | |
""" | |
if self.formatVersion is None: | |
return (5, 0) | |
numbers = (int(i) for i in self.formatVersion.split(".")) | |
major = next(numbers) | |
minor = next(numbers, 0) | |
return (major, minor) | |
def getVariableFonts(self) -> List[VariableFontDescriptor]: | |
"""Return all variable fonts defined in this document, or implicit | |
variable fonts that can be built from the document's continuous axes. | |
In the case of Designspace documents before version 5, the whole | |
document was implicitly describing a variable font that covers the | |
whole space. | |
In version 5 and above documents, there can be as many variable fonts | |
as there are locations on discrete axes. | |
.. seealso:: :func:`splitInterpolable` | |
.. versionadded:: 5.0 | |
""" | |
if self.variableFonts: | |
return self.variableFonts | |
variableFonts = [] | |
discreteAxes = [] | |
rangeAxisSubsets: List[ | |
Union[RangeAxisSubsetDescriptor, ValueAxisSubsetDescriptor] | |
] = [] | |
for axis in self.axes: | |
if hasattr(axis, "values"): | |
# Mypy doesn't support narrowing union types via hasattr() | |
# TODO(Python 3.10): use TypeGuard | |
# https://mypy.readthedocs.io/en/stable/type_narrowing.html | |
axis = cast(DiscreteAxisDescriptor, axis) | |
discreteAxes.append(axis) # type: ignore | |
else: | |
rangeAxisSubsets.append(RangeAxisSubsetDescriptor(name=axis.name)) | |
valueCombinations = itertools.product(*[axis.values for axis in discreteAxes]) | |
for values in valueCombinations: | |
basename = None | |
if self.filename is not None: | |
basename = os.path.splitext(self.filename)[0] + "-VF" | |
if self.path is not None: | |
basename = os.path.splitext(os.path.basename(self.path))[0] + "-VF" | |
if basename is None: | |
basename = "VF" | |
axisNames = "".join( | |
[f"-{axis.tag}{value}" for axis, value in zip(discreteAxes, values)] | |
) | |
variableFonts.append( | |
VariableFontDescriptor( | |
name=f"{basename}{axisNames}", | |
axisSubsets=rangeAxisSubsets | |
+ [ | |
ValueAxisSubsetDescriptor(name=axis.name, userValue=value) | |
for axis, value in zip(discreteAxes, values) | |
], | |
) | |
) | |
return variableFonts | |
def deepcopyExceptFonts(self): | |
"""Allow deep-copying a DesignSpace document without deep-copying | |
attached UFO fonts or TTFont objects. The :attr:`font` attribute | |
is shared by reference between the original and the copy. | |
.. versionadded:: 5.0 | |
""" | |
fonts = [source.font for source in self.sources] | |
try: | |
for source in self.sources: | |
source.font = None | |
res = copy.deepcopy(self) | |
for source, font in zip(res.sources, fonts): | |
source.font = font | |
return res | |
finally: | |
for source, font in zip(self.sources, fonts): | |
source.font = font | |
def main(args=None): | |
"""Roundtrip .designspace file through the DesignSpaceDocument class""" | |
if args is None: | |
import sys | |
args = sys.argv[1:] | |
from argparse import ArgumentParser | |
parser = ArgumentParser(prog="designspaceLib", description=main.__doc__) | |
parser.add_argument("input") | |
parser.add_argument("output") | |
options = parser.parse_args(args) | |
ds = DesignSpaceDocument.fromfile(options.input) | |
ds.write(options.output) | |