blob: 80ba9d6d7b44f58773f42107d672c13651c166a9 [file] [log] [blame]
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List, Optional, Union, cast
from fontTools.designspaceLib import (
AxisDescriptor,
DesignSpaceDocument,
DesignSpaceDocumentError,
RangeAxisSubsetDescriptor,
SimpleLocationDict,
ValueAxisSubsetDescriptor,
VariableFontDescriptor,
)
def clamp(value, minimum, maximum):
return min(max(value, minimum), maximum)
@dataclass
class Range:
minimum: float
"""Inclusive minimum of the range."""
maximum: float
"""Inclusive maximum of the range."""
default: float = 0
"""Default value"""
def __post_init__(self):
self.minimum, self.maximum = sorted((self.minimum, self.maximum))
self.default = clamp(self.default, self.minimum, self.maximum)
def __contains__(self, value: Union[float, Range]) -> bool:
if isinstance(value, Range):
return self.minimum <= value.minimum and value.maximum <= self.maximum
return self.minimum <= value <= self.maximum
def intersection(self, other: Range) -> Optional[Range]:
if self.maximum < other.minimum or self.minimum > other.maximum:
return None
else:
return Range(
max(self.minimum, other.minimum),
min(self.maximum, other.maximum),
self.default, # We don't care about the default in this use-case
)
# A region selection is either a range or a single value, as a Designspace v5
# axis-subset element only allows a single discrete value or a range for a
# variable-font element.
Region = Dict[str, Union[Range, float]]
# A conditionset is a set of named ranges.
ConditionSet = Dict[str, Range]
# A rule is a list of conditionsets where any has to be relevant for the whole rule to be relevant.
Rule = List[ConditionSet]
Rules = Dict[str, Rule]
def locationInRegion(location: SimpleLocationDict, region: Region) -> bool:
for name, value in location.items():
if name not in region:
return False
regionValue = region[name]
if isinstance(regionValue, (float, int)):
if value != regionValue:
return False
else:
if value not in regionValue:
return False
return True
def regionInRegion(region: Region, superRegion: Region) -> bool:
for name, value in region.items():
if not name in superRegion:
return False
superValue = superRegion[name]
if isinstance(superValue, (float, int)):
if value != superValue:
return False
else:
if value not in superValue:
return False
return True
def userRegionToDesignRegion(doc: DesignSpaceDocument, userRegion: Region) -> Region:
designRegion = {}
for name, value in userRegion.items():
axis = doc.getAxis(name)
if axis is None:
raise DesignSpaceDocumentError(
f"Cannot find axis named '{name}' for region."
)
if isinstance(value, (float, int)):
designRegion[name] = axis.map_forward(value)
else:
designRegion[name] = Range(
axis.map_forward(value.minimum),
axis.map_forward(value.maximum),
axis.map_forward(value.default),
)
return designRegion
def getVFUserRegion(doc: DesignSpaceDocument, vf: VariableFontDescriptor) -> Region:
vfUserRegion: Region = {}
# For each axis, 2 cases:
# - it has a range = it's an axis in the VF DS
# - it's a single location = use it to know which rules should apply in the VF
for axisSubset in vf.axisSubsets:
axis = doc.getAxis(axisSubset.name)
if axis is None:
raise DesignSpaceDocumentError(
f"Cannot find axis named '{axisSubset.name}' for variable font '{vf.name}'."
)
if hasattr(axisSubset, "userMinimum"):
# Mypy doesn't support narrowing union types via hasattr()
# TODO(Python 3.10): use TypeGuard
# https://mypy.readthedocs.io/en/stable/type_narrowing.html
axisSubset = cast(RangeAxisSubsetDescriptor, axisSubset)
if not hasattr(axis, "minimum"):
raise DesignSpaceDocumentError(
f"Cannot select a range over '{axis.name}' for variable font '{vf.name}' "
"because it's a discrete axis, use only 'userValue' instead."
)
axis = cast(AxisDescriptor, axis)
vfUserRegion[axis.name] = Range(
max(axisSubset.userMinimum, axis.minimum),
min(axisSubset.userMaximum, axis.maximum),
axisSubset.userDefault or axis.default,
)
else:
axisSubset = cast(ValueAxisSubsetDescriptor, axisSubset)
vfUserRegion[axis.name] = axisSubset.userValue
# Any axis not mentioned explicitly has a single location = default value
for axis in doc.axes:
if axis.name not in vfUserRegion:
assert isinstance(
axis.default, (int, float)
), f"Axis '{axis.name}' has no valid default value."
vfUserRegion[axis.name] = axis.default
return vfUserRegion