1from __future__ import annotations 2 3from dataclasses import dataclass 4from typing import Dict, List, Optional, Union, cast 5 6from fontTools.designspaceLib import ( 7 AxisDescriptor, 8 DesignSpaceDocument, 9 DesignSpaceDocumentError, 10 RangeAxisSubsetDescriptor, 11 SimpleLocationDict, 12 ValueAxisSubsetDescriptor, 13 VariableFontDescriptor, 14) 15 16 17def clamp(value, minimum, maximum): 18 return min(max(value, minimum), maximum) 19 20 21@dataclass 22class Range: 23 minimum: float 24 """Inclusive minimum of the range.""" 25 maximum: float 26 """Inclusive maximum of the range.""" 27 default: float = 0 28 """Default value""" 29 30 def __post_init__(self): 31 self.minimum, self.maximum = sorted((self.minimum, self.maximum)) 32 self.default = clamp(self.default, self.minimum, self.maximum) 33 34 def __contains__(self, value: Union[float, Range]) -> bool: 35 if isinstance(value, Range): 36 return self.minimum <= value.minimum and value.maximum <= self.maximum 37 return self.minimum <= value <= self.maximum 38 39 def intersection(self, other: Range) -> Optional[Range]: 40 if self.maximum < other.minimum or self.minimum > other.maximum: 41 return None 42 else: 43 return Range( 44 max(self.minimum, other.minimum), 45 min(self.maximum, other.maximum), 46 self.default, # We don't care about the default in this use-case 47 ) 48 49 50# A region selection is either a range or a single value, as a Designspace v5 51# axis-subset element only allows a single discrete value or a range for a 52# variable-font element. 53Region = Dict[str, Union[Range, float]] 54 55# A conditionset is a set of named ranges. 56ConditionSet = Dict[str, Range] 57 58# A rule is a list of conditionsets where any has to be relevant for the whole rule to be relevant. 59Rule = List[ConditionSet] 60Rules = Dict[str, Rule] 61 62 63def locationInRegion(location: SimpleLocationDict, region: Region) -> bool: 64 for name, value in location.items(): 65 if name not in region: 66 return False 67 regionValue = region[name] 68 if isinstance(regionValue, (float, int)): 69 if value != regionValue: 70 return False 71 else: 72 if value not in regionValue: 73 return False 74 return True 75 76 77def regionInRegion(region: Region, superRegion: Region) -> bool: 78 for name, value in region.items(): 79 if not name in superRegion: 80 return False 81 superValue = superRegion[name] 82 if isinstance(superValue, (float, int)): 83 if value != superValue: 84 return False 85 else: 86 if value not in superValue: 87 return False 88 return True 89 90 91def userRegionToDesignRegion(doc: DesignSpaceDocument, userRegion: Region) -> Region: 92 designRegion = {} 93 for name, value in userRegion.items(): 94 axis = doc.getAxis(name) 95 if axis is None: 96 raise DesignSpaceDocumentError( 97 f"Cannot find axis named '{name}' for region." 98 ) 99 if isinstance(value, (float, int)): 100 designRegion[name] = axis.map_forward(value) 101 else: 102 designRegion[name] = Range( 103 axis.map_forward(value.minimum), 104 axis.map_forward(value.maximum), 105 axis.map_forward(value.default), 106 ) 107 return designRegion 108 109 110def getVFUserRegion(doc: DesignSpaceDocument, vf: VariableFontDescriptor) -> Region: 111 vfUserRegion: Region = {} 112 # For each axis, 2 cases: 113 # - it has a range = it's an axis in the VF DS 114 # - it's a single location = use it to know which rules should apply in the VF 115 for axisSubset in vf.axisSubsets: 116 axis = doc.getAxis(axisSubset.name) 117 if axis is None: 118 raise DesignSpaceDocumentError( 119 f"Cannot find axis named '{axisSubset.name}' for variable font '{vf.name}'." 120 ) 121 if hasattr(axisSubset, "userMinimum"): 122 # Mypy doesn't support narrowing union types via hasattr() 123 # TODO(Python 3.10): use TypeGuard 124 # https://mypy.readthedocs.io/en/stable/type_narrowing.html 125 axisSubset = cast(RangeAxisSubsetDescriptor, axisSubset) 126 if not hasattr(axis, "minimum"): 127 raise DesignSpaceDocumentError( 128 f"Cannot select a range over '{axis.name}' for variable font '{vf.name}' " 129 "because it's a discrete axis, use only 'userValue' instead." 130 ) 131 axis = cast(AxisDescriptor, axis) 132 vfUserRegion[axis.name] = Range( 133 max(axisSubset.userMinimum, axis.minimum), 134 min(axisSubset.userMaximum, axis.maximum), 135 axisSubset.userDefault or axis.default, 136 ) 137 else: 138 axisSubset = cast(ValueAxisSubsetDescriptor, axisSubset) 139 vfUserRegion[axis.name] = axisSubset.userValue 140 # Any axis not mentioned explicitly has a single location = default value 141 for axis in doc.axes: 142 if axis.name not in vfUserRegion: 143 assert isinstance( 144 axis.default, (int, float) 145 ), f"Axis '{axis.name}' has no valid default value." 146 vfUserRegion[axis.name] = axis.default 147 return vfUserRegion 148