[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):
"""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",

View File

@ -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):