[instancer] Allow relaxed limits syntax (#3323)

* [instancer] Allow relaxed limits syntax

Fixes https://github.com/fonttools/fonttools/issues/3322

Co-authored-by: Cosimo Lupo <clupo@google.com>
This commit is contained in:
Behdad Esfahbod 2023-11-02 10:22:23 -06:00 committed by GitHub
parent 2d9b80acd1
commit ae69e9e8be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 75 additions and 39 deletions

View File

@ -139,26 +139,36 @@ def NormalizedAxisRange(minimum, maximum):
class AxisTriple(Sequence): class AxisTriple(Sequence):
"""A triple of (min, default, max) axis values. """A triple of (min, default, max) axis values.
The default value can be None, in which case the limitRangeAndPopulateDefault() Any of the values can be None, in which case the limitRangeAndPopulateDefaults()
method can be used to fill in the missing default value based on the fvar axis method can be used to fill in the missing values based on the fvar axis values.
default.
""" """
minimum: float minimum: Optional[float]
default: Optional[float] # if None, filled with by limitRangeAndPopulateDefault default: Optional[float]
maximum: float maximum: Optional[float]
def __post_init__(self): def __post_init__(self):
if self.default is None and self.minimum == self.maximum: if self.default is None and self.minimum == self.maximum:
object.__setattr__(self, "default", self.minimum) object.__setattr__(self, "default", self.minimum)
if not ( if (
(self.minimum <= self.default <= self.maximum) (
if self.default is not None self.minimum is not None
else (self.minimum <= self.maximum) 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( raise ValueError(
f"{type(self).__name__} minimum ({self.minimum}) must be <= default " f"{type(self).__name__} minimum ({self.minimum}), default ({self.default}), maximum ({self.maximum}) must be in sorted order"
f"({self.default}) which must be <= maximum ({self.maximum})"
) )
def __getitem__(self, i): def __getitem__(self, i):
@ -210,7 +220,7 @@ class AxisTriple(Sequence):
raise ValueError(f"expected sequence of 2 or 3; got {n}: {v!r}") raise ValueError(f"expected sequence of 2 or 3; got {n}: {v!r}")
return cls(minimum, default, maximum) 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. """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, 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. If the default value is already set, return self.
""" """
minimum = self.minimum minimum = self.minimum
maximum = self.maximum if minimum is None:
minimum = fvarTriple[0]
default = self.default default = self.default
if default is None: if default is None:
default = fvarTriple[1] default = fvarTriple[1]
maximum = self.maximum
if maximum is None:
maximum = fvarTriple[2]
minimum = max(self.minimum, fvarTriple[0]) minimum = max(minimum, fvarTriple[0])
maximum = max(self.maximum, fvarTriple[0]) maximum = max(maximum, fvarTriple[0])
minimum = min(minimum, fvarTriple[2]) minimum = min(minimum, fvarTriple[2])
maximum = min(maximum, fvarTriple[2]) maximum = min(maximum, fvarTriple[2])
default = max(minimum, min(maximum, default)) default = max(minimum, min(maximum, default))
@ -372,7 +386,7 @@ class AxisLimits(_BaseAxisLimits):
if triple is None: if triple is None:
newLimits[axisTag] = AxisTriple(default, default, default) newLimits[axisTag] = AxisTriple(default, default, default)
else: else:
newLimits[axisTag] = triple.limitRangeAndPopulateDefault(fvarTriple) newLimits[axisTag] = triple.limitRangeAndPopulateDefaults(fvarTriple)
return type(self)(newLimits) return type(self)(newLimits)
def normalize(self, varfont, usingAvar=True) -> "NormalizedAxisLimits": def normalize(self, varfont, usingAvar=True) -> "NormalizedAxisLimits":
@ -1326,29 +1340,27 @@ def parseLimits(limits: Iterable[str]) -> Dict[str, Optional[AxisTriple]]:
result = {} result = {}
for limitString in limits: for limitString in limits:
match = re.match( match = re.match(
r"^(\w{1,4})=(?:(drop)|(?:([^:]+)(?:[:]([^:]+))?(?:[:]([^:]+))?))$", r"^(\w{1,4})=(?:(drop)|(?:([^:]*)(?:[:]([^:]*))?(?:[:]([^:]*))?))$",
limitString, limitString,
) )
if not match: if not match:
raise ValueError("invalid location format: %r" % limitString) raise ValueError("invalid location format: %r" % limitString)
tag = match.group(1).ljust(4) 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 result[tag] = None
continue 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 return result
@ -1377,9 +1389,11 @@ def parseArgs(args):
metavar="AXIS=LOC", metavar="AXIS=LOC",
nargs="*", nargs="*",
help="List of space separated locations. A location consists of " help="List of space separated locations. A location consists of "
"the tag of a variation axis, followed by '=' and one of number, " "the tag of a variation axis, followed by '=' and the literal, "
"number:number or the literal string 'drop'. " "string 'drop', or comma-separate list of one to three values, "
"E.g.: wdth=100 or wght=75.0:125.0 or wght=drop", "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( parser.add_argument(
"-o", "-o",

View File

@ -1280,16 +1280,29 @@ class InstantiateFvarTest(object):
assert "fvar" not in varfont assert "fvar" not in varfont
def test_out_of_range_instance(self, varfont): @pytest.mark.parametrize(
location = instancer.AxisLimits({"wght": (30, 40, 700)}) "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) varfont = instancer.instantiateVariableFont(varfont, location)
fvar = varfont["fvar"] fvar = varfont["fvar"]
axes = {a.axisTag: a for a in fvar.axes} axes = {a.axisTag: a for a in fvar.axes}
assert axes["wght"].minValue == 100 assert axes["wght"].minValue == expected[0]
assert axes["wght"].defaultValue == 100 assert axes["wght"].defaultValue == expected[1]
assert axes["wght"].maxValue == 700 assert axes["wght"].maxValue == expected[2]
class InstantiateSTATTest(object): class InstantiateSTATTest(object):
@ -2085,6 +2098,15 @@ def test_limitFeatureVariationConditionRange(oldRange, newLimit, expected):
(["wght=400:700:900"], {"wght": (400, 700, 900)}), (["wght=400:700:900"], {"wght": (400, 700, 900)}),
(["slnt=11.4"], {"slnt": 11.399994}), (["slnt=11.4"], {"slnt": 11.399994}),
(["ABCD=drop"], {"ABCD": None}), (["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): def test_parseLimits(limits, expected):