diff --git a/Lib/fontTools/varLib/instancer/__init__.py b/Lib/fontTools/varLib/instancer/__init__.py index 046c79fa2..77762979c 100644 --- a/Lib/fontTools/varLib/instancer/__init__.py +++ b/Lib/fontTools/varLib/instancer/__init__.py @@ -139,26 +139,36 @@ def NormalizedAxisRange(minimum, maximum): class AxisTriple(Sequence): """A triple of (min, default, max) axis values. - The default value can be None, in which case the limitRangeAndPopulateDefault() - method can be used to fill in the missing default value based on the fvar axis - default. + Any of the values can be None, in which case the limitRangeAndPopulateDefaults() + method can be used to fill in the missing values based on the fvar axis values. """ - minimum: float - default: Optional[float] # if None, filled with by limitRangeAndPopulateDefault - maximum: float + minimum: Optional[float] + default: Optional[float] + maximum: Optional[float] def __post_init__(self): if self.default is None and self.minimum == self.maximum: object.__setattr__(self, "default", self.minimum) - if not ( - (self.minimum <= self.default <= self.maximum) - if self.default is not None - else (self.minimum <= self.maximum) + if ( + ( + self.minimum is not None + and self.default is not None + and self.minimum > self.default + ) + or ( + self.default is not None + and self.maximum is not None + and self.default > self.maximum + ) + or ( + self.minimum is not None + and self.maximum is not None + and self.minimum > self.maximum + ) ): raise ValueError( - f"{type(self).__name__} minimum ({self.minimum}) must be <= default " - f"({self.default}) which must be <= maximum ({self.maximum})" + f"{type(self).__name__} minimum ({self.minimum}), default ({self.default}), maximum ({self.maximum}) must be in sorted order" ) def __getitem__(self, i): @@ -210,7 +220,7 @@ class AxisTriple(Sequence): raise ValueError(f"expected sequence of 2 or 3; got {n}: {v!r}") return cls(minimum, default, maximum) - def limitRangeAndPopulateDefault(self, fvarTriple) -> "AxisTriple": + def limitRangeAndPopulateDefaults(self, fvarTriple) -> "AxisTriple": """Return a new AxisTriple with the default value filled in. Set default to fvar axis default if the latter is within the min/max range, @@ -219,13 +229,17 @@ class AxisTriple(Sequence): If the default value is already set, return self. """ minimum = self.minimum - maximum = self.maximum + if minimum is None: + minimum = fvarTriple[0] default = self.default if default is None: default = fvarTriple[1] + maximum = self.maximum + if maximum is None: + maximum = fvarTriple[2] - minimum = max(self.minimum, fvarTriple[0]) - maximum = max(self.maximum, fvarTriple[0]) + minimum = max(minimum, fvarTriple[0]) + maximum = max(maximum, fvarTriple[0]) minimum = min(minimum, fvarTriple[2]) maximum = min(maximum, fvarTriple[2]) default = max(minimum, min(maximum, default)) @@ -372,7 +386,7 @@ class AxisLimits(_BaseAxisLimits): if triple is None: newLimits[axisTag] = AxisTriple(default, default, default) else: - newLimits[axisTag] = triple.limitRangeAndPopulateDefault(fvarTriple) + newLimits[axisTag] = triple.limitRangeAndPopulateDefaults(fvarTriple) return type(self)(newLimits) def normalize(self, varfont, usingAvar=True) -> "NormalizedAxisLimits": @@ -1326,29 +1340,27 @@ def parseLimits(limits: Iterable[str]) -> Dict[str, Optional[AxisTriple]]: result = {} for limitString in limits: match = re.match( - r"^(\w{1,4})=(?:(drop)|(?:([^:]+)(?:[:]([^:]+))?(?:[:]([^:]+))?))$", + r"^(\w{1,4})=(?:(drop)|(?:([^:]*)(?:[:]([^:]*))?(?:[:]([^:]*))?))$", limitString, ) if not match: raise ValueError("invalid location format: %r" % limitString) tag = match.group(1).ljust(4) - if match.group(2): # 'drop' - lbound = None - else: - lbound = strToFixedToFloat(match.group(3), precisionBits=16) - ubound = default = lbound - if match.group(4): - ubound = default = strToFixedToFloat(match.group(4), precisionBits=16) - default = None - if match.group(5): - default = ubound - ubound = strToFixedToFloat(match.group(5), precisionBits=16) - if all(v is None for v in (lbound, default, ubound)): + if match.group(2): # 'drop' result[tag] = None continue - result[tag] = AxisTriple(lbound, default, ubound) + triple = match.group(3, 4, 5) + + if triple[1] is None: # "value" syntax + triple = (triple[0], triple[0], triple[0]) + elif triple[2] is None: # "min:max" syntax + triple = (triple[0], None, triple[1]) + + triple = tuple(float(v) if v else None for v in triple) + + result[tag] = AxisTriple(*triple) return result @@ -1377,9 +1389,11 @@ def parseArgs(args): metavar="AXIS=LOC", nargs="*", help="List of space separated locations. A location consists of " - "the tag of a variation axis, followed by '=' and one of number, " - "number:number or the literal string 'drop'. " - "E.g.: wdth=100 or wght=75.0:125.0 or wght=drop", + "the tag of a variation axis, followed by '=' and the literal, " + "string 'drop', or comma-separate list of one to three values, " + "each of which is the empty string, or a number. " + "E.g.: wdth=100 or wght=75.0:125.0 or wght=100:400:700 or wght=:500: " + "or wght=drop", ) parser.add_argument( "-o", diff --git a/Tests/varLib/instancer/instancer_test.py b/Tests/varLib/instancer/instancer_test.py index 0b0702d7a..20d9194f6 100644 --- a/Tests/varLib/instancer/instancer_test.py +++ b/Tests/varLib/instancer/instancer_test.py @@ -1280,16 +1280,29 @@ class InstantiateFvarTest(object): assert "fvar" not in varfont - def test_out_of_range_instance(self, varfont): - location = instancer.AxisLimits({"wght": (30, 40, 700)}) + @pytest.mark.parametrize( + "location, expected", + [ + ({"wght": (30, 40, 700)}, (100, 100, 700)), + ({"wght": (30, 40, None)}, (100, 100, 900)), + ({"wght": (30, None, 700)}, (100, 400, 700)), + ({"wght": (None, 200, 700)}, (100, 200, 700)), + ({"wght": (40, None, None)}, (100, 400, 900)), + ({"wght": (None, 40, None)}, (100, 100, 900)), + ({"wght": (None, None, 700)}, (100, 400, 700)), + ({"wght": (None, None, None)}, (100, 400, 900)), + ], + ) + def test_axis_limits(self, varfont, location, expected): + location = instancer.AxisLimits(location) varfont = instancer.instantiateVariableFont(varfont, location) fvar = varfont["fvar"] axes = {a.axisTag: a for a in fvar.axes} - assert axes["wght"].minValue == 100 - assert axes["wght"].defaultValue == 100 - assert axes["wght"].maxValue == 700 + assert axes["wght"].minValue == expected[0] + assert axes["wght"].defaultValue == expected[1] + assert axes["wght"].maxValue == expected[2] class InstantiateSTATTest(object): @@ -2085,6 +2098,15 @@ def test_limitFeatureVariationConditionRange(oldRange, newLimit, expected): (["wght=400:700:900"], {"wght": (400, 700, 900)}), (["slnt=11.4"], {"slnt": 11.399994}), (["ABCD=drop"], {"ABCD": None}), + (["wght=:500:"], {"wght": (None, 500, None)}), + (["wght=::700"], {"wght": (None, None, 700)}), + (["wght=200::"], {"wght": (200, None, None)}), + (["wght=200:300:"], {"wght": (200, 300, None)}), + (["wght=:300:500"], {"wght": (None, 300, 500)}), + (["wght=300::700"], {"wght": (300, None, 700)}), + (["wght=300:700"], {"wght": (300, None, 700)}), + (["wght=:700"], {"wght": (None, None, 700)}), + (["wght=200:"], {"wght": (200, None, None)}), ], ) def test_parseLimits(limits, expected):