| 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 |