diff --git a/Lib/fontTools/varLib/instancer/__init__.py b/Lib/fontTools/varLib/instancer/__init__.py index cf2a736f8..62551cff8 100644 --- a/Lib/fontTools/varLib/instancer/__init__.py +++ b/Lib/fontTools/varLib/instancer/__init__.py @@ -51,19 +51,30 @@ Note that, unlike varLib.mutator, when an axis is not mentioned in the input location, the varLib.instancer will keep the axis and the corresponding deltas, whereas mutator implicitly drops the axis at its default coordinate. -The module currently supports only the first three "levels" of partial instancing, -with the rest planned to be implemented in the future, namely: +The module supports all the following "levels" of instancing, which can of +course be combined: L1 dropping one or more axes while leaving the default tables unmodified; + + | >>> font = instancer.instantiateVariableFont(varfont, {"wght": None}) + L2 dropping one or more axes while pinning them at non-default locations; + + | >>> font = instancer.instantiateVariableFont(varfont, {"wght": 700}) + L3 restricting the range of variation of one or more axes, by setting either a new minimum or maximum, potentially -- though not necessarily -- dropping entire regions of variations that fall completely outside this new range. + + | >>> font = instancer.instantiateVariableFont(varfont, {"wght": (100, 300)}) + L4 - moving the default location of an axis. + moving the default location of an axis, by specifying (min,defalt,max) values: + + | >>> font = instancer.instantiateVariableFont(varfont, {"wght": (100, 300, 700)}) Currently only TrueType-flavored variable fonts (i.e. containing 'glyf' table) are supported, but support for CFF2 variable fonts will be added soon. @@ -92,65 +103,254 @@ from fontTools.varLib.instancer import names from fontTools.misc.cliTools import makeOutputFileName from fontTools.varLib.instancer import solver import collections +import dataclasses from copy import deepcopy from enum import IntEnum import logging import os import re +from typing import Dict, Iterable, Mapping, Optional, Sequence, Tuple, Union +import warnings log = logging.getLogger("fontTools.varLib.instancer") -def _expand(v): - if not isinstance(v, tuple): - return (v, v, v) - else: - if len(v) == 2: - return (v[0], None, v[1]) - return v +def AxisRange(minimum, maximum): + warnings.warn( + "AxisRange is deprecated; use AxisTriple instead", + DeprecationWarning, + stacklevel=2, + ) + return AxisTriple(minimum, None, maximum) -class AxisRange(collections.namedtuple("AxisRange", "minimum maximum")): - def __new__(cls, *args, **kwargs): - self = super().__new__(cls, *args, **kwargs) - if self.minimum > self.maximum: +def NormalizedAxisRange(minimum, maximum): + warnings.warn( + "NormalizedAxisRange is deprecated; use AxisTriple instead", + DeprecationWarning, + stacklevel=2, + ) + return NormalizedAxisTriple(minimum, None, maximum) + + +@dataclasses.dataclass(frozen=True, order=True, repr=False) +class AxisTriple(Sequence): + """A triple of (min, default, max) axis values. + + The default value can be None, in which case the populateDefault() method can be + used to fill in the missing default value based on the fvar axis default. + """ + + minimum: float + default: Optional[float] # if None, filled with fvar default by populateDefault + maximum: 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) + ): raise ValueError( - f"Range minimum ({self.minimum:g}) must be <= maximum ({self.maximum:g})" + f"{type(self).__name__} minimum ({self.minimum}) must be <= default " + f"({self.default}) which must be <= maximum ({self.maximum})" ) - return self + + def __getitem__(self, i): + fields = dataclasses.fields(self) + return getattr(self, fields[i].name) + + def __len__(self): + return len(dataclasses.fields(self)) + + def _replace(self, **kwargs): + return dataclasses.replace(self, **kwargs) def __repr__(self): - return f"{type(self).__name__}({self.minimum:g}, {self.maximum:g})" + return ( + f"({', '.join(format(v, 'g') if v is not None else 'None' for v in self)})" + ) + @classmethod + def expand( + cls, + v: Union[ + "AxisTriple", + float, # pin axis at single value, same as min==default==max + Tuple[float, float], # (min, max), restrict axis and keep default + Tuple[float, float, float], # (min, default, max) + ], + ) -> "AxisTriple": + """Convert a single value or a tuple into an AxisTriple. -class NormalizedAxisRange(AxisRange): - def __new__(cls, *args, **kwargs): - self = super().__new__(cls, *args, **kwargs) - if self.minimum < -1.0 or self.maximum > 1.0: - raise ValueError("Axis range values must be normalized to -1..+1 range") - return self - - -class AxisTent(collections.namedtuple("AxisTent", "minimum default maximum")): - def __new__(cls, *args, **kwargs): - self = super().__new__(cls, *args, **kwargs) - if not (self.minimum <= self.default <= self.maximum): + If the input is a single value, it is interpreted as a pin at that value. + If the input is a tuple, it is interpreted as (min, max) or (min, default, max). + """ + if isinstance(v, cls): + return v + if isinstance(v, (int, float)): + return cls(v, v, v) + try: + n = len(v) + except TypeError as e: raise ValueError( - f"Tent minimum ({self.minimum:g}) must be <= default ({self.default:g}) which must be <= maximum ({self.maximum:g})" + f"expected float, 2- or 3-tuple of floats; got {type(v)}: {v!r}" + ) from e + default = None + if n == 2: + minimum, maximum = v + elif n == 3: + minimum, default, maximum = v + else: + raise ValueError(f"expected sequence of 2 or 3; got {n}: {v!r}") + return cls(minimum, default, maximum) + + def populateDefault(self, fvarAxisDefault) -> "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, + otherwise set default to the min or max value, whichever is closer to the + fvar axis default. + If the default value is already set, return self. + """ + if self.default is not None: + return self + default = max(self.minimum, min(self.maximum, fvarAxisDefault)) + return dataclasses.replace(self, default=default) + + +@dataclasses.dataclass(frozen=True, order=True, repr=False) +class NormalizedAxisTriple(AxisTriple): + """A triple of (min, default, max) normalized axis values.""" + + minimum: float + default: float + maximum: float + + def __post_init__(self): + if self.default is None: + object.__setattr__(self, "default", max(self.minimum, min(self.maximum, 0))) + if not (-1.0 <= self.minimum <= self.default <= self.maximum <= 1.0): + raise ValueError( + "Normalized axis values not in -1..+1 range; got " + f"minimum={self.minimum:g}, default={self.default:g}, maximum={self.maximum:g})" ) - return self - - def __repr__(self): - return f"{type(self).__name__}({self.minimum:g}, {self.default:g}, {self.maximum:g})" -class NormalizedAxisTent(AxisTent): - def __new__(cls, *args, **kwargs): - self = super().__new__(cls, *args, **kwargs) - if self.minimum < -1.0 or self.maximum > 1.0: - raise ValueError("Axis tent values must be normalized to -1..+1 range") - return self +class _BaseAxisLimits(Mapping[str, AxisTriple]): + def __getitem__(self, key: str) -> AxisTriple: + return self._data[key] + + def __iter__(self) -> Iterable[str]: + return iter(self._data) + + def __len__(self) -> int: + return len(self._data) + + def __repr__(self) -> str: + return f"{type(self).__name__}({self._data!r})" + + def __str__(self) -> str: + return str(self._data) + + def pinnedLocation(self) -> Dict[str, float]: + """Return a location dict with only the pinned axes.""" + return {k: v.default for k, v in self.items() if v.minimum == v.maximum} + + +class AxisLimits(_BaseAxisLimits): + """Maps axis tags (str) to AxisTriple values.""" + + def __init__(self, *args, **kwargs): + self.have_defaults = True + self._data = data = {} + for k, v in dict(*args, **kwargs).items(): + if v is None: + # will be filled in by populateDefaults + self.have_defaults = False + data[k] = v + else: + try: + triple = AxisTriple.expand(v) + except ValueError as e: + raise ValueError(f"Invalid axis limits for {k!r}: {v!r}") from e + if triple.default is None: + # also filled in by populateDefaults + self.have_defaults = False + data[k] = triple + + def populateDefaults(self, varfont) -> "AxisLimits": + """Return a new AxisLimits with defaults filled in from fvar table. + + If all axis limits already have defaults, return self. + """ + if self.have_defaults: + return self + fvar = varfont["fvar"] + defaultValues = {a.axisTag: a.defaultValue for a in fvar.axes} + newLimits = {} + for axisTag, triple in self.items(): + default = defaultValues[axisTag] + if triple is None: + newLimits[axisTag] = AxisTriple(default, default, default) + else: + newLimits[axisTag] = triple.populateDefault(default) + return type(self)(newLimits) + + def normalize(self, varfont, usingAvar=True) -> "NormalizedAxisLimits": + """Return a new NormalizedAxisLimits with normalized -1..0..+1 values. + + If usingAvar is True, the avar table is used to warp the default normalization. + """ + fvar = varfont["fvar"] + badLimits = set(self.keys()).difference(a.axisTag for a in fvar.axes) + if badLimits: + raise ValueError("Cannot limit: {} not present in fvar".format(badLimits)) + + axes = { + a.axisTag: (a.minValue, a.defaultValue, a.maxValue) + for a in fvar.axes + if a.axisTag in self + } + + avarSegments = {} + if usingAvar and "avar" in varfont: + avarSegments = varfont["avar"].segments + + normalizedLimits = {} + + for axis_tag, triple in axes.items(): + if self[axis_tag] is None: + normalizedLimits[axis_tag] = NormalizedAxisTriple(0, 0, 0) + continue + + minV, defaultV, maxV = self[axis_tag] + + if defaultV is None: + defaultV = triple[1] + + avarMapping = avarSegments.get(axis_tag, None) + normalizedLimits[axis_tag] = NormalizedAxisTriple( + *(normalize(v, triple, avarMapping) for v in (minV, defaultV, maxV)) + ) + + return NormalizedAxisLimits(normalizedLimits) + + +class NormalizedAxisLimits(_BaseAxisLimits): + """Maps axis tags (str) to NormalizedAxisTriple values.""" + + def __init__(self, *args, **kwargs): + self._data = data = {} + for k, v in dict(*args, **kwargs).items(): + try: + triple = NormalizedAxisTriple.expand(v) + except ValueError as e: + raise ValueError(f"Invalid axis limits for {k!r}: {v!r}") from e + data[k] = triple class OverlapMode(IntEnum): @@ -166,8 +366,9 @@ def instantiateTupleVariationStore( """Instantiate TupleVariation list at the given location, or limit axes' min/max. The 'variations' list of TupleVariation objects is modified in-place. - The 'axisLimits' (dict) maps axis tags (str) to either a single coordinate along the - axis (float), or to minimum/maximum coordinates (NormalizedAxisRange). + The 'axisLimits' (dict) maps axis tags (str) to NormalizedAxisTriple namedtuples + specifying (minimum, default, maximum) in the -1,0,+1 normalized space. Pinned axes + have minimum == default == maximum. A 'full' instance (i.e. static font) is produced when all the axes are pinned to single coordinates; a 'partial' instance (i.e. a less variable font) is produced @@ -184,8 +385,8 @@ def instantiateTupleVariationStore( Args: variations: List[TupleVariation] from either 'gvar' or 'cvar'. - axisLimits: Dict[str, Union[float, NormalizedAxisRange]]: axes' coordinates for - the full or partial instance, or ranges for restricting an axis' min/max. + axisLimits: NormalizedAxisLimits: map from axis tags to (min, default, max) + normalized coordinates for the full or partial instance. origCoords: GlyphCoordinates: default instance's coordinates for computing 'gvar' inferred points (cf. table__g_l_y_f._getCoordinatesAndControls). endPts: List[int]: indices of contour end points, for inferring 'gvar' deltas. @@ -230,11 +431,7 @@ def changeTupleVariationsAxisLimits(variations, axisLimits): def changeTupleVariationAxisLimit(var, axisTag, axisLimit): - if not isinstance(axisLimit, tuple): - axisLimit = NormalizedAxisTent(axisLimit, axisLimit, axisLimit) - if isinstance(axisLimit, NormalizedAxisRange): - axisLimit = NormalizedAxisTent(axisLimit.minimum, 0, axisLimit.maximum) - assert isinstance(axisLimit, NormalizedAxisTent) + assert isinstance(axisLimit, NormalizedAxisTriple) # Skip when current axis is missing (i.e. doesn't participate), lower, peak, upper = var.axes.get(axisTag, (-1, 0, 1)) @@ -409,9 +606,7 @@ def _remapVarIdxMap(table, attrName, varIndexMapping, glyphOrder): # TODO(anthrotype) Add support for HVAR/VVAR in CFF2 def _instantiateVHVAR(varfont, axisLimits, tableFields): - location, axisRanges = splitAxisLocationAndRanges( - axisLimits, rangeType=NormalizedAxisRange - ) + location = axisLimits.pinnedLocation() tableTag = tableFields.tableTag fvarAxes = varfont["fvar"].axes # Deltas from gvar table have already been applied to the hmtx/vmtx. For full @@ -510,11 +705,7 @@ class _TupleVarStoreAdapter(object): # rebuild regions whose axes were dropped or limited self.rebuildRegions() - pinnedAxes = { - axisTag - for axisTag, v in axisLimits.items() - if (v.minimum == v.maximum if isinstance(v, tuple) else True) - } + pinnedAxes = set(axisLimits.pinnedLocation()) self.axisOrder = [ axisTag for axisTag in self.axisOrder if axisTag not in pinnedAxes ] @@ -559,9 +750,9 @@ def instantiateItemVariationStore(itemVarStore, fvarAxes, axisLimits): Args: varStore: An otTables.VarStore object (Item Variation Store) fvarAxes: list of fvar's Axis objects - axisLimits: Dict[str, float] mapping axis tags to normalized axis coordinates - (float) or ranges for restricting an axis' min/max (NormalizedAxisRange). - May not specify coordinates/ranges for all the fvar axes. + axisLimits: NormalizedAxisLimits: mapping axis tags to normalized + min/default/max axis coordinates. May not specify coordinates/ranges for + all the fvar axes. Returns: defaultDeltas: to be added to the default instance, of type dict of floats @@ -691,11 +882,7 @@ def _limitFeatureVariationConditionRange(condition, axisLimit): # condition invalid or out of range return - values = [minValue, maxValue] - for i, value in enumerate(values): - values[i] = normalizeValue(value, axisLimit) - - return AxisRange(*values) + return tuple(normalizeValue(v, axisLimit) for v in (minValue, maxValue)) def _instantiateFeatureVariationRecord( @@ -703,6 +890,7 @@ def _instantiateFeatureVariationRecord( ): applies = True newConditions = [] + default_triple = NormalizedAxisTriple(-1, 0, +1) for i, condition in enumerate(record.ConditionSet.ConditionTable): if condition.Format == 1: axisIdx = condition.AxisIndex @@ -710,12 +898,11 @@ def _instantiateFeatureVariationRecord( minValue = condition.FilterRangeMinValue maxValue = condition.FilterRangeMaxValue - triple = _expand(axisLimits.get(axisTag, (-1, 0, +1))) - v = triple[1] - if not (minValue <= v <= maxValue): + triple = axisLimits.get(axisTag, default_triple) + if not (minValue <= triple.default <= maxValue): applies = False # condition not met so remove entire record - if triple[0] > maxValue or triple[2] < minValue: + if triple.minimum > maxValue or triple.maximum < minValue: newConditions = None break @@ -742,7 +929,7 @@ def _instantiateFeatureVariationRecord( def _limitFeatureVariationRecord(record, axisLimits, axisOrder): newConditions = [] - for i, condition in enumerate(record.ConditionSet.ConditionTable): + for condition in record.ConditionSet.ConditionTable: if condition.Format == 1: axisIdx = condition.AxisIndex axisTag = axisOrder[axisIdx] @@ -750,10 +937,11 @@ def _limitFeatureVariationRecord(record, axisLimits, axisOrder): axisLimit = axisLimits[axisTag] newRange = _limitFeatureVariationConditionRange(condition, axisLimit) if newRange: - # keep condition with updated limits and remapped axis index - condition.FilterRangeMinValue = newRange.minimum - condition.FilterRangeMaxValue = newRange.maximum - if newRange.minimum != -1 or newRange.maximum != +1: + # keep condition with updated limits + minimum, maximum = newRange + condition.FilterRangeMinValue = minimum + condition.FilterRangeMaxValue = maximum + if minimum != -1 or maximum != +1: newConditions.append(condition) else: # condition out of range, remove entire record @@ -769,10 +957,7 @@ def _limitFeatureVariationRecord(record, axisLimits, axisOrder): def _instantiateFeatureVariations(table, fvarAxes, axisLimits): - location, axisRanges = splitAxisLocationAndRanges( - axisLimits, rangeType=NormalizedAxisRange - ) - pinnedAxes = set(location.keys()) + pinnedAxes = set(axisLimits.pinnedLocation()) axisOrder = [axis.axisTag for axis in fvarAxes if axis.axisTag not in pinnedAxes] axisIndexMap = {axisTag: axisOrder.index(axisTag) for axisTag in axisOrder} @@ -833,12 +1018,10 @@ def _isValidAvarSegmentMap(axisTag, segmentMap): def instantiateAvar(varfont, axisLimits): # 'axisLimits' dict must contain user-space (non-normalized) coordinates. - location, axisRanges = splitAxisLocationAndRanges(axisLimits) - segments = varfont["avar"].segments # drop table if we instantiate all the axes - pinnedAxes = set(location.keys()) + pinnedAxes = set(axisLimits.pinnedLocation()) if pinnedAxes.issuperset(segments): log.info("Dropping avar table") del varfont["avar"] @@ -849,7 +1032,7 @@ def instantiateAvar(varfont, axisLimits): if axis in segments: del segments[axis] - # First compute the default normalization for axisRanges coordinates: i.e. + # First compute the default normalization for axisLimits coordinates: i.e. # min = -1.0, default = 0, max = +1.0, and in between values interpolated linearly, # without using the avar table's mappings. # Then, for each SegmentMap, if we are restricting its axis, compute the new @@ -857,7 +1040,7 @@ def instantiateAvar(varfont, axisLimits): # dropping any mappings that fall outside the restricted range. # The keys ('fromCoord') are specified in default normalized coordinate space, # whereas the values ('toCoord') are "mapped forward" using the SegmentMap. - normalizedRanges = normalizeAxisLimits(varfont, axisLimits, usingAvar=False) + normalizedRanges = axisLimits.normalize(varfont, usingAvar=False) newSegments = {} for axisTag, mapping in segments.items(): if not _isValidAvarSegmentMap(axisTag, mapping): @@ -905,7 +1088,7 @@ def isInstanceWithinAxisRanges(location, axisRanges): def instantiateFvar(varfont, axisLimits): # 'axisLimits' dict must contain user-space (non-normalized) coordinates - location, axisRanges = splitAxisLocationAndRanges(axisLimits, rangeType=AxisRange) + location = axisLimits.pinnedLocation() fvar = varfont["fvar"] @@ -937,7 +1120,7 @@ def instantiateFvar(varfont, axisLimits): continue for axisTag in location: del instance.coordinates[axisTag] - if not isInstanceWithinAxisRanges(instance.coordinates, axisRanges): + if not isInstanceWithinAxisRanges(instance.coordinates, axisLimits): continue instances.append(instance) fvar.instances = instances @@ -962,14 +1145,10 @@ def instantiateSTAT(varfont, axisLimits): def axisValuesFromAxisLimits(stat, axisLimits): - location, axisRanges = splitAxisLocationAndRanges(axisLimits, rangeType=AxisRange) - def isAxisValueOutsideLimits(axisTag, axisValue): - if axisTag in location and axisValue != location[axisTag]: - return True - elif axisTag in axisRanges: - axisRange = axisRanges[axisTag] - if axisValue < axisRange.minimum or axisValue > axisRange.maximum: + if axisTag in axisLimits: + triple = axisLimits[axisTag] + if axisValue < triple.minimum or axisValue > triple.maximum: return True return False @@ -1026,43 +1205,6 @@ def normalize(value, triple, avarMapping): return floatToFixedToFloat(value, 14) -def normalizeAxisLimits(varfont, axisLimits, usingAvar=True): - fvar = varfont["fvar"] - badLimits = set(axisLimits.keys()).difference(a.axisTag for a in fvar.axes) - if badLimits: - raise ValueError("Cannot limit: {} not present in fvar".format(badLimits)) - - axes = { - a.axisTag: (a.minValue, a.defaultValue, a.maxValue) - for a in fvar.axes - if a.axisTag in axisLimits - } - - avarSegments = {} - if usingAvar and "avar" in varfont: - avarSegments = varfont["avar"].segments - - normalizedLimits = {} - - for axis_tag, triple in axes.items(): - default = triple[1] - - value = axisLimits[axis_tag] - - minV, defaultV, maxV = _expand(value) - if defaultV is None: - defaultV = default - - value = (minV, defaultV, maxV) - - avarMapping = avarSegments.get(axis_tag, None) - normalizedLimits[axis_tag] = NormalizedAxisTent( - *(normalize(v, triple, avarMapping) for v in value) - ) - - return normalizedLimits - - def sanityCheckVariableTables(varfont): if "fvar" not in varfont: raise ValueError("Missing required table fvar") @@ -1074,19 +1216,6 @@ def sanityCheckVariableTables(varfont): raise NotImplementedError("Instancing CFF2 variable fonts is not supported yet") -def populateAxisDefaults(varfont, axisLimits): - if any(None in _expand(value) for value in axisLimits.values()): - fvar = varfont["fvar"] - defaultValues = {a.axisTag: a.defaultValue for a in fvar.axes} - return { - axisTag: tuple( - defaultValues[axisTag] if v is None else v for v in _expand(value) - ) - for axisTag, value in axisLimits.items() - } - return axisLimits - - def instantiateVariableFont( varfont, axisLimits, @@ -1139,9 +1268,9 @@ def instantiateVariableFont( sanityCheckVariableTables(varfont) - axisLimits = populateAxisDefaults(varfont, axisLimits) + axisLimits = AxisLimits(axisLimits).populateDefaults(varfont) - normalizedLimits = normalizeAxisLimits(varfont, axisLimits) + normalizedLimits = axisLimits.normalize(varfont) log.info("Normalized limits: %s", normalizedLimits) @@ -1193,14 +1322,7 @@ def instantiateVariableFont( ignoreErrors=(overlap == OverlapMode.REMOVE_AND_IGNORE_ERRORS), ) - varLib.set_default_weight_width_slant( - varfont, - location={ - axisTag: _expand(limit)[1] - for axisTag, limit in axisLimits.items() - if _expand(limit)[0] == _expand(limit)[2] - }, - ) + varLib.set_default_weight_width_slant(varfont, location=axisLimits.pinnedLocation()) if updateFontNames: # Set Regular/Italic/Bold/Bold Italic bits as appropriate, after the @@ -1247,18 +1369,7 @@ def setRibbiBits(font): font["OS/2"].fsSelection = selection -def splitAxisLocationAndRanges(axisLimits, rangeType=AxisRange): - location, axisRanges = {}, {} - for axisTag, value in axisLimits.items(): - (minimum, default, maximum) = _expand(value) - if minimum == default == maximum: - location[axisTag] = default - else: - axisRanges[axisTag] = rangeType(minimum, maximum) - return location, axisRanges - - -def parseLimits(limits): +def parseLimits(limits: Iterable[str]) -> Dict[str, Optional[AxisTriple]]: result = {} for limitString in limits: match = re.match( @@ -1280,20 +1391,11 @@ def parseLimits(limits): default = ubound ubound = strToFixedToFloat(match.group(5), precisionBits=16) - if lbound is not None: - if default is None: - assert lbound <= ubound - else: - assert lbound <= default <= ubound - - if lbound == default == ubound: - result[tag] = lbound - continue - elif default is None: - result[tag] = AxisRange(lbound, ubound) + if all(v is None for v in (lbound, default, ubound)): + result[tag] = None continue - result[tag] = AxisTent(lbound, default, ubound) + result[tag] = AxisTriple(lbound, default, ubound) return result diff --git a/Lib/fontTools/varLib/instancer/names.py b/Lib/fontTools/varLib/instancer/names.py index c0430198a..27b56b50c 100644 --- a/Lib/fontTools/varLib/instancer/names.py +++ b/Lib/fontTools/varLib/instancer/names.py @@ -86,7 +86,7 @@ def updateNameTable(varfont, axisLimits): Example: Updating a partial variable font: | >>> ttFont = TTFont("OpenSans[wdth,wght].ttf") - | >>> updateNameTable(ttFont, {"wght": AxisRange(400, 900), "wdth": 75}) + | >>> updateNameTable(ttFont, {"wght": (400, 900), "wdth": 75}) The name table records will be updated in the following manner: NameID 1 familyName: "Open Sans" --> "Open Sans Condensed" @@ -102,7 +102,7 @@ def updateNameTable(varfont, axisLimits): https://docs.microsoft.com/en-us/typography/opentype/spec/stat https://docs.microsoft.com/en-us/typography/opentype/spec/name#name-ids """ - from . import AxisRange, axisValuesFromAxisLimits + from . import AxisLimits, axisValuesFromAxisLimits if "STAT" not in varfont: raise ValueError("Cannot update name table since there is no STAT table.") @@ -113,17 +113,15 @@ def updateNameTable(varfont, axisLimits): # The updated name table will reflect the new 'zero origin' of the font. # If we're instantiating a partial font, we will populate the unpinned - # axes with their default axis values. + # axes with their default axis values from fvar. + axisLimits = AxisLimits(axisLimits).populateDefaults(varfont) + partialDefaults = {k: v.default for k, v in axisLimits.items()} fvarDefaults = {a.axisTag: a.defaultValue for a in fvar.axes} - defaultAxisCoords = deepcopy(axisLimits) - for axisTag, val in fvarDefaults.items(): - if axisTag not in defaultAxisCoords or isinstance( - defaultAxisCoords[axisTag], AxisRange - ): - defaultAxisCoords[axisTag] = val + defaultAxisCoords = AxisLimits({**fvarDefaults, **partialDefaults}) + assert all(v.minimum == v.maximum for v in defaultAxisCoords.values()) axisValueTables = axisValuesFromAxisLimits(stat, defaultAxisCoords) - checkAxisValuesExist(stat, axisValueTables, defaultAxisCoords) + checkAxisValuesExist(stat, axisValueTables, defaultAxisCoords.pinnedLocation()) # ignore "elidable" axis values, should be omitted in application font menus. axisValueTables = [ @@ -154,8 +152,8 @@ def checkAxisValuesExist(stat, axisValues, axisCoords): missingAxes = set(axisCoords) - seen if missingAxes: - missing = ", ".join(f"'{i}={axisCoords[i]}'" for i in missingAxes) - raise ValueError(f"Cannot find Axis Values [{missing}]") + missing = ", ".join(f"'{i}': {axisCoords[i]}" for i in missingAxes) + raise ValueError(f"Cannot find Axis Values {{{missing}}}") def _sortAxisValues(axisValues): diff --git a/Tests/varLib/instancer/data/PartialInstancerTest-VF.ttx b/Tests/varLib/instancer/data/PartialInstancerTest-VF.ttx index 268b5068b..781e106de 100644 --- a/Tests/varLib/instancer/data/PartialInstancerTest-VF.ttx +++ b/Tests/varLib/instancer/data/PartialInstancerTest-VF.ttx @@ -728,7 +728,7 @@ - + @@ -743,7 +743,13 @@ - + + + + + + + @@ -751,7 +757,7 @@ - + @@ -760,14 +766,14 @@ - + - + diff --git a/Tests/varLib/instancer/instancer_test.py b/Tests/varLib/instancer/instancer_test.py index bee5fd938..f4d266dd4 100644 --- a/Tests/varLib/instancer/instancer_test.py +++ b/Tests/varLib/instancer/instancer_test.py @@ -112,6 +112,8 @@ class InstantiateGvarTest(object): ], ) def test_pin_and_drop_axis(self, varfont, glyph_name, location, expected, optimize): + location = instancer.NormalizedAxisLimits(location) + instancer.instantiateGvar(varfont, location, optimize=optimize) assert _get_coordinates(varfont, glyph_name) == expected[glyph_name] @@ -124,9 +126,9 @@ class InstantiateGvarTest(object): ) def test_full_instance(self, varfont, optimize): - instancer.instantiateGvar( - varfont, {"wght": 0.0, "wdth": -0.5}, optimize=optimize - ) + location = instancer.NormalizedAxisLimits(wght=0.0, wdth=-0.5) + + instancer.instantiateGvar(varfont, location, optimize=optimize) assert _get_coordinates(varfont, "hyphen") == [ (33.5, 229), @@ -169,7 +171,7 @@ class InstantiateGvarTest(object): assert hmtx["minus"] == (422, 40) assert vmtx["minus"] == (536, 229) - location = {"wght": -1.0, "wdth": -1.0} + location = instancer.NormalizedAxisLimits(wght=-1.0, wdth=-1.0) instancer.instantiateGvar(varfont, location) @@ -206,6 +208,8 @@ class InstantiateCvarTest(object): ], ) def test_pin_and_drop_axis(self, varfont, location, expected): + location = instancer.NormalizedAxisLimits(location) + instancer.instantiateCvar(varfont, location) assert list(varfont["cvt "].values) == expected @@ -217,7 +221,9 @@ class InstantiateCvarTest(object): ) def test_full_instance(self, varfont): - instancer.instantiateCvar(varfont, {"wght": -0.5, "wdth": -0.5}) + location = instancer.NormalizedAxisLimits(wght=-0.5, wdth=-0.5) + + instancer.instantiateCvar(varfont, location) assert list(varfont["cvt "].values) == [500, -400, 165, 225] @@ -272,6 +278,8 @@ class InstantiateMVARTest(object): assert mvar.VarStore.VarData[1].VarRegionCount == 1 assert all(len(item) == 1 for item in mvar.VarStore.VarData[1].Item) + location = instancer.NormalizedAxisLimits(location) + instancer.instantiateMVAR(varfont, location) for mvar_tag, expected_value in expected.items(): @@ -312,6 +320,8 @@ class InstantiateMVARTest(object): ], ) def test_full_instance(self, varfont, location, expected): + location = instancer.NormalizedAxisLimits(location) + instancer.instantiateMVAR(varfont, location) for mvar_tag, expected_value in expected.items(): @@ -344,6 +354,8 @@ class InstantiateHVARTest(object): ], ) def test_partial_instance(self, varfont, location, expectedRegions, expectedDeltas): + location = instancer.NormalizedAxisLimits(location) + instancer.instantiateHVAR(varfont, location) assert "HVAR" in varfont @@ -376,7 +388,9 @@ class InstantiateHVARTest(object): assert varStore.VarData[varIdx >> 16].Item[varIdx & 0xFFFF] == expectedDeltas def test_full_instance(self, varfont): - instancer.instantiateHVAR(varfont, {"wght": 0, "wdth": 0}) + location = instancer.NormalizedAxisLimits(wght=0, wdth=0) + + instancer.instantiateHVAR(varfont, location) assert "HVAR" not in varfont @@ -390,7 +404,9 @@ class InstantiateHVARTest(object): axis.axisTag = "TEST" fvar.axes.append(axis) - instancer.instantiateHVAR(varfont, {"wght": 0, "wdth": 0}) + location = instancer.NormalizedAxisLimits(wght=0, wdth=0) + + instancer.instantiateHVAR(varfont, location) assert "HVAR" in varfont @@ -452,6 +468,8 @@ class InstantiateItemVariationStoreTest(object): def test_instantiate_default_deltas( self, varStore, fvarAxes, location, expected_deltas, num_regions ): + location = instancer.NormalizedAxisLimits(location) + defaultDeltas = instancer.instantiateItemVariationStore( varStore, fvarAxes, location ) @@ -504,8 +522,9 @@ class TupleVarStoreAdapterTest(object): adapter = instancer._TupleVarStoreAdapter( regions, axisOrder, tupleVarData, itemCounts=[2, 2] ) + location = instancer.NormalizedAxisLimits(wght=0.5) - defaultDeltaArray = adapter.instantiate({"wght": 0.5}) + defaultDeltaArray = adapter.instantiate(location) assert defaultDeltaArray == [[15, 45], [0, 0]] assert adapter.regions == [{"wdth": (-1.0, -1.0, 0)}] @@ -747,6 +766,8 @@ class InstantiateOTLTest(object): vf = varfontGDEF assert "GDEF" in vf + location = instancer.NormalizedAxisLimits(location) + instancer.instantiateOTL(vf, location) assert "GDEF" in vf @@ -778,6 +799,8 @@ class InstantiateOTLTest(object): vf = varfontGDEF assert "GDEF" in vf + location = instancer.NormalizedAxisLimits(location) + instancer.instantiateOTL(vf, location) assert "GDEF" in vf @@ -806,6 +829,8 @@ class InstantiateOTLTest(object): assert "GDEF" in vf assert "GPOS" in vf + location = instancer.NormalizedAxisLimits(location) + instancer.instantiateOTL(vf, location) gdef = vf["GDEF"].table @@ -839,6 +864,8 @@ class InstantiateOTLTest(object): assert "GDEF" in vf assert "GPOS" in vf + location = instancer.NormalizedAxisLimits(location) + instancer.instantiateOTL(vf, location) assert "GDEF" not in vf @@ -870,6 +897,8 @@ class InstantiateOTLTest(object): assert "GDEF" in vf assert "GPOS" in vf + location = instancer.NormalizedAxisLimits(location) + instancer.instantiateOTL(vf, location) v1, v2 = expected @@ -915,6 +944,8 @@ class InstantiateOTLTest(object): assert "GDEF" in vf assert "GPOS" in vf + location = instancer.NormalizedAxisLimits(location) + instancer.instantiateOTL(vf, location) v1, v2 = expected @@ -955,7 +986,7 @@ class InstantiateOTLTest(object): # check that MutatorMerger for ValueRecord doesn't raise AttributeError # when XAdvDevice is present but there's no corresponding XAdvance. - instancer.instantiateOTL(vf, {"wght": 0.5}) + instancer.instantiateOTL(vf, instancer.NormalizedAxisLimits(wght=0.5)) pairPos = vf["GPOS"].table.LookupList.Lookup[0].SubTable[0] assert pairPos.ValueFormat1 == 0x4 @@ -967,12 +998,16 @@ class InstantiateOTLTest(object): class InstantiateAvarTest(object): @pytest.mark.parametrize("location", [{"wght": 0.0}, {"wdth": 0.0}]) def test_pin_and_drop_axis(self, varfont, location): + location = instancer.AxisLimits(location) + instancer.instantiateAvar(varfont, location) assert set(varfont["avar"].segments).isdisjoint(location) def test_full_instance(self, varfont): - instancer.instantiateAvar(varfont, {"wght": 0.0, "wdth": 0.0}) + location = instancer.AxisLimits(wght=0.0, wdth=0.0) + + instancer.instantiateAvar(varfont, location) assert "avar" not in varfont @@ -1139,6 +1174,8 @@ class InstantiateAvarTest(object): ], ) def test_limit_axes(self, varfont, axisLimits, expectedSegments): + axisLimits = instancer.AxisLimits(axisLimits) + instancer.instantiateAvar(varfont, axisLimits) newSegments = varfont["avar"].segments @@ -1162,8 +1199,10 @@ class InstantiateAvarTest(object): def test_drop_invalid_segment_map(self, varfont, invalidSegmentMap, caplog): varfont["avar"].segments["wght"] = invalidSegmentMap + axisLimits = instancer.AxisLimits(wght=(100, 400)) + with caplog.at_level(logging.WARNING, logger="fontTools.varLib.instancer"): - instancer.instantiateAvar(varfont, {"wght": (100, 400)}) + instancer.instantiateAvar(varfont, axisLimits) assert "Invalid avar" in caplog.text assert "wght" not in varfont["avar"].segments @@ -1210,6 +1249,8 @@ class InstantiateFvarTest(object): ], ) def test_pin_and_drop_axis(self, varfont, location, instancesLeft): + location = instancer.AxisLimits(location) + instancer.instantiateFvar(varfont, location) fvar = varfont["fvar"] @@ -1224,7 +1265,9 @@ class InstantiateFvarTest(object): ] == instancesLeft def test_full_instance(self, varfont): - instancer.instantiateFvar(varfont, {"wght": 0.0, "wdth": 0.0}) + location = instancer.AxisLimits({"wght": 0.0, "wdth": 0.0}) + + instancer.instantiateFvar(varfont, location) assert "fvar" not in varfont @@ -1234,10 +1277,15 @@ class InstantiateSTATTest(object): "location, expected", [ ({"wght": 400}, ["Regular", "Condensed", "Upright", "Normal"]), - ({"wdth": 100}, ["Thin", "Regular", "Black", "Upright", "Normal"]), + ( + {"wdth": 100}, + ["Thin", "Regular", "Medium", "Black", "Upright", "Normal"], + ), ], ) def test_pin_and_drop_axis(self, varfont, location, expected): + location = instancer.AxisLimits(location) + instancer.instantiateSTAT(varfont, location) stat = varfont["STAT"].table @@ -1256,7 +1304,7 @@ class InstantiateSTATTest(object): def test_skip_table_no_axis_value_array(self, varfont): varfont["STAT"].table.AxisValueArray = None - instancer.instantiateSTAT(varfont, {"wght": 100}) + instancer.instantiateSTAT(varfont, instancer.AxisLimits(wght=100)) assert len(varfont["STAT"].table.DesignAxisRecord.Axis) == 3 assert varfont["STAT"].table.AxisValueArray is None @@ -1318,7 +1366,9 @@ class InstantiateSTATTest(object): return result def test_limit_axes(self, varfont2): - instancer.instantiateSTAT(varfont2, {"wght": (400, 500), "wdth": (75, 100)}) + axisLimits = instancer.AxisLimits({"wght": (400, 500), "wdth": (75, 100)}) + + instancer.instantiateSTAT(varfont2, axisLimits) assert len(varfont2["STAT"].table.AxisValueArray.AxisValue) == 5 assert self.get_STAT_axis_values(varfont2["STAT"].table) == [ @@ -1344,11 +1394,11 @@ class InstantiateSTATTest(object): axisValue.AxisValueRecord.append(rec) stat.AxisValueArray.AxisValue.append(axisValue) - instancer.instantiateSTAT(varfont2, {"wght": (100, 600)}) + instancer.instantiateSTAT(varfont2, instancer.AxisLimits(wght=(100, 600))) assert axisValue in varfont2["STAT"].table.AxisValueArray.AxisValue - instancer.instantiateSTAT(varfont2, {"wdth": (62.5, 87.5)}) + instancer.instantiateSTAT(varfont2, instancer.AxisLimits(wdth=(62.5, 87.5))) assert axisValue not in varfont2["STAT"].table.AxisValueArray.AxisValue @@ -1359,7 +1409,7 @@ class InstantiateSTATTest(object): stat.AxisValueArray.AxisValue.append(axisValue) with caplog.at_level(logging.WARNING, logger="fontTools.varLib.instancer"): - instancer.instantiateSTAT(varfont2, {"wght": 400}) + instancer.instantiateSTAT(varfont2, instancer.AxisLimits(wght=400)) assert "Unknown AxisValue table format (5)" in caplog.text assert axisValue in varfont2["STAT"].table.AxisValueArray.AxisValue @@ -1482,17 +1532,15 @@ class InstantiateVariableFontTest(object): location = {"wght": 280, "opsz": 18} instance = instancer.instantiateVariableFont( - varfont, location, + varfont, + location, ) - expected = _get_expected_instance_ttx( - "SinglePos", *location.values() - ) + expected = _get_expected_instance_ttx("SinglePos", *location.values()) assert _dump_ttx(instance) == expected - def _conditionSetAsDict(conditionSet, axisOrder): result = {} for cond in conditionSet.ConditionTable: @@ -1577,15 +1625,15 @@ class InstantiateFeatureVariationsTest(object): ], ), ( - {"cntr": (-.5, 0, 1.0)}, + {"cntr": (-0.5, 0, 1.0)}, {}, [ ( - {"wght": (0.20886, 1.0), "cntr": (.75, 1)}, + {"wght": (0.20886, 1.0), "cntr": (0.75, 1)}, {"uni0024": "uni0024.nostroke", "uni0041": "uni0061"}, ), ( - {"wght": (-1.0, -0.45654), "cntr": (0, .25)}, + {"wght": (-1.0, -0.45654), "cntr": (0, 0.25)}, {"uni0061": "uni0041"}, ), ( @@ -1599,7 +1647,7 @@ class InstantiateFeatureVariationsTest(object): ], ), ( - {"cntr": (.8, .9, 1.0)}, + {"cntr": (0.8, 0.9, 1.0)}, {"uni0041": "uni0061"}, [ ( @@ -1626,8 +1674,7 @@ class InstantiateFeatureVariationsTest(object): ] ) - limits = {tag:instancer.NormalizedAxisTent(l, l, l) if not isinstance(l, tuple) else instancer.NormalizedAxisTent(*l) - for tag,l in location.items()} + limits = instancer.NormalizedAxisLimits(location) instancer.instantiateFeatureVariations(font, limits) gsub = font["GSUB"].table @@ -1635,7 +1682,11 @@ class InstantiateFeatureVariationsTest(object): assert featureVariations.FeatureVariationCount == len(expectedRecords) - axisOrder = [a.axisTag for a in font["fvar"].axes if a.axisTag not in location or isinstance(location[a.axisTag], tuple)] + axisOrder = [ + a.axisTag + for a in font["fvar"].axes + if a.axisTag not in location or isinstance(location[a.axisTag], tuple) + ] for i, (expectedConditionSet, expectedSubs) in enumerate(expectedRecords): rec = featureVariations.FeatureVariationRecord[i] conditionSet = _conditionSetAsDict(rec.ConditionSet, axisOrder) @@ -1681,6 +1732,8 @@ class InstantiateFeatureVariationsTest(object): assert gsub.FeatureVariations assert gsub.Version == 0x00010001 + location = instancer.NormalizedAxisLimits(location) + instancer.instantiateFeatureVariations(font, location) assert not hasattr(gsub, "FeatureVariations") @@ -1707,7 +1760,9 @@ class InstantiateFeatureVariationsTest(object): rec1.ConditionSet.ConditionTable[0].Format = 2 with caplog.at_level(logging.WARNING, logger="fontTools.varLib.instancer"): - instancer.instantiateFeatureVariations(font, {"wdth": instancer.NormalizedAxisTent(0,0,0)}) + instancer.instantiateFeatureVariations( + font, instancer.NormalizedAxisLimits(wdth=0) + ) assert ( "Condition table 0 of FeatureVariationRecord 0 " @@ -1819,7 +1874,7 @@ class LimitTupleVariationAxisRangesTest: ], ) def test_positive_var(self, var, axisTag, newMax, expected): - axisRange = instancer.NormalizedAxisRange(0, newMax) + axisRange = instancer.NormalizedAxisTriple(0, 0, newMax) self.check_limit_single_var_axis_range(var, axisTag, axisRange, expected) @pytest.mark.parametrize( @@ -1898,7 +1953,7 @@ class LimitTupleVariationAxisRangesTest: ], ) def test_negative_var(self, var, axisTag, newMin, expected): - axisRange = instancer.NormalizedAxisRange(newMin, 0) + axisRange = instancer.NormalizedAxisTriple(newMin, 0, 0) self.check_limit_single_var_axis_range(var, axisTag, axisRange, expected) @@ -1921,7 +1976,7 @@ def test_limitFeatureVariationConditionRange(oldRange, newLimit, expected): condition = featureVars.buildConditionTable(0, *oldRange) result = instancer._limitFeatureVariationConditionRange( - condition, instancer.NormalizedAxisTent(*newLimit) + condition, instancer.NormalizedAxisTriple(*newLimit) ) assert result == expected @@ -1933,12 +1988,23 @@ def test_limitFeatureVariationConditionRange(oldRange, newLimit, expected): (["wght=400", "wdth=100"], {"wght": 400, "wdth": 100}), (["wght=400:900"], {"wght": (400, 900)}), (["wght=400:700:900"], {"wght": (400, 700, 900)}), - (["slnt=11.4"], {"slnt": pytest.approx(11.399994)}), + (["slnt=11.4"], {"slnt": 11.399994}), (["ABCD=drop"], {"ABCD": None}), ], ) def test_parseLimits(limits, expected): - assert instancer.parseLimits(limits) == expected + limits = instancer.parseLimits(limits) + expected = instancer.AxisLimits(expected) + + assert limits.keys() == expected.keys() + for axis, triple in limits.items(): + expected_triple = expected[axis] + if expected_triple is None: + assert triple is None + else: + assert isinstance(triple, instancer.AxisTriple) + assert isinstance(expected_triple, instancer.AxisTriple) + assert triple == pytest.approx(expected_triple) @pytest.mark.parametrize( @@ -1949,22 +2015,34 @@ def test_parseLimits_invalid(limits): instancer.parseLimits(limits) -def test_normalizeAxisLimits_tuple(varfont): - normalized = instancer.normalizeAxisLimits(varfont, {"wght": (100, 400)}) - assert normalized == {"wght": (-1.0, 0, 0)} +@pytest.mark.parametrize( + "limits, expected", + [ + ({"wght": (100, 400)}, {"wght": (-1.0, 0, 0)}), + ({"wght": (100, 400, 400)}, {"wght": (-1.0, 0, 0)}), + ({"wght": (100, 300, 400)}, {"wght": (-1.0, -0.5, 0)}), + ], +) +def test_normalizeAxisLimits(varfont, limits, expected): + limits = instancer.AxisLimits(limits) + + normalized = limits.normalize(varfont) + + assert normalized == instancer.NormalizedAxisLimits(expected) def test_normalizeAxisLimits_no_avar(varfont): del varfont["avar"] - normalized = instancer.normalizeAxisLimits(varfont, {"wght": (400, 500)}) + limits = instancer.AxisLimits(wght=(400, 400, 500)) + normalized = limits.normalize(varfont) assert normalized["wght"] == pytest.approx((0, 0, 0.2), 1e-4) def test_normalizeAxisLimits_missing_from_fvar(varfont): with pytest.raises(ValueError, match="not present in fvar"): - instancer.normalizeAxisLimits(varfont, {"ZZZZ": 1000}) + instancer.AxisLimits({"ZZZZ": 1000}).normalize(varfont) def test_sanityCheckVariableTables(varfont): diff --git a/Tests/varLib/instancer/names_test.py b/Tests/varLib/instancer/names_test.py index 9774458a9..7aeee6547 100644 --- a/Tests/varLib/instancer/names_test.py +++ b/Tests/varLib/instancer/names_test.py @@ -115,7 +115,7 @@ def _test_name_records(varfont, expected, isNonRIBBI, platforms=[0x409]): ), # Condensed with unpinned weights ( - {"wdth": 79, "wght": instancer.AxisRange(400, 900)}, + {"wdth": 79, "wght": (400, 900)}, { (1, 3, 1, 0x409): "Test Variable Font Condensed", (2, 3, 1, 0x409): "Regular", @@ -126,6 +126,19 @@ def _test_name_records(varfont, expected, isNonRIBBI, platforms=[0x409]): }, True, ), + # Restrict weight and move default, new minimum (500) > old default (400) + ( + {"wght": (500, 900)}, + { + (1, 3, 1, 0x409): "Test Variable Font Medium", + (2, 3, 1, 0x409): "Regular", + (3, 3, 1, 0x409): "2.001;GOOG;TestVariableFont-Medium", + (6, 3, 1, 0x409): "TestVariableFont-Medium", + (16, 3, 1, 0x409): "Test Variable Font", + (17, 3, 1, 0x409): "Medium", + }, + True, + ), ], ) def test_updateNameTable_with_registered_axes_ribbi( @@ -215,7 +228,7 @@ def test_updateNameTable_with_multilingual_names(varfont, limits, expected, isNo def test_updateNameTable_missing_axisValues(varfont): - with pytest.raises(ValueError, match="Cannot find Axis Values \['wght=200'\]"): + with pytest.raises(ValueError, match="Cannot find Axis Values {'wght': 200}"): instancer.names.updateNameTable(varfont, {"wght": 200}) @@ -257,7 +270,7 @@ def test_updateNameTable_missing_stat(varfont): def test_updateNameTable_vf_with_italic_attribute( varfont, limits, expected, isNonRIBBI ): - font_link_axisValue = varfont["STAT"].table.AxisValueArray.AxisValue[4] + font_link_axisValue = varfont["STAT"].table.AxisValueArray.AxisValue[5] # Unset ELIDABLE_AXIS_VALUE_NAME flag font_link_axisValue.Flags &= ~instancer.names.ELIDABLE_AXIS_VALUE_NAME font_link_axisValue.ValueNameID = 294 # Roman --> Italic