xref: /aosp_15_r20/external/fonttools/Lib/fontTools/designspaceLib/types.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
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