123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584 |
- """Variation fonts interpolation models."""
- __all__ = [
- "normalizeValue",
- "normalizeLocation",
- "supportScalar",
- "piecewiseLinearMap",
- "VariationModel",
- ]
- from fontTools.misc.roundTools import noRound
- from .errors import VariationModelError
- def nonNone(lst):
- return [l for l in lst if l is not None]
- def allNone(lst):
- return all(l is None for l in lst)
- def allEqualTo(ref, lst, mapper=None):
- if mapper is None:
- return all(ref == item for item in lst)
- mapped = mapper(ref)
- return all(mapped == mapper(item) for item in lst)
- def allEqual(lst, mapper=None):
- if not lst:
- return True
- it = iter(lst)
- try:
- first = next(it)
- except StopIteration:
- return True
- return allEqualTo(first, it, mapper=mapper)
- def subList(truth, lst):
- assert len(truth) == len(lst)
- return [l for l, t in zip(lst, truth) if t]
- def normalizeValue(v, triple, extrapolate=False):
- """Normalizes value based on a min/default/max triple.
- >>> normalizeValue(400, (100, 400, 900))
- 0.0
- >>> normalizeValue(100, (100, 400, 900))
- -1.0
- >>> normalizeValue(650, (100, 400, 900))
- 0.5
- """
- lower, default, upper = triple
- if not (lower <= default <= upper):
- raise ValueError(
- f"Invalid axis values, must be minimum, default, maximum: "
- f"{lower:3.3f}, {default:3.3f}, {upper:3.3f}"
- )
- if not extrapolate:
- v = max(min(v, upper), lower)
- if v == default or lower == upper:
- return 0.0
- if (v < default and lower != default) or (v > default and upper == default):
- return (v - default) / (default - lower)
- else:
- assert (v > default and upper != default) or (
- v < default and lower == default
- ), f"Ooops... v={v}, triple=({lower}, {default}, {upper})"
- return (v - default) / (upper - default)
- def normalizeLocation(location, axes, extrapolate=False):
- """Normalizes location based on axis min/default/max values from axes.
- >>> axes = {"wght": (100, 400, 900)}
- >>> normalizeLocation({"wght": 400}, axes)
- {'wght': 0.0}
- >>> normalizeLocation({"wght": 100}, axes)
- {'wght': -1.0}
- >>> normalizeLocation({"wght": 900}, axes)
- {'wght': 1.0}
- >>> normalizeLocation({"wght": 650}, axes)
- {'wght': 0.5}
- >>> normalizeLocation({"wght": 1000}, axes)
- {'wght': 1.0}
- >>> normalizeLocation({"wght": 0}, axes)
- {'wght': -1.0}
- >>> axes = {"wght": (0, 0, 1000)}
- >>> normalizeLocation({"wght": 0}, axes)
- {'wght': 0.0}
- >>> normalizeLocation({"wght": -1}, axes)
- {'wght': 0.0}
- >>> normalizeLocation({"wght": 1000}, axes)
- {'wght': 1.0}
- >>> normalizeLocation({"wght": 500}, axes)
- {'wght': 0.5}
- >>> normalizeLocation({"wght": 1001}, axes)
- {'wght': 1.0}
- >>> axes = {"wght": (0, 1000, 1000)}
- >>> normalizeLocation({"wght": 0}, axes)
- {'wght': -1.0}
- >>> normalizeLocation({"wght": -1}, axes)
- {'wght': -1.0}
- >>> normalizeLocation({"wght": 500}, axes)
- {'wght': -0.5}
- >>> normalizeLocation({"wght": 1000}, axes)
- {'wght': 0.0}
- >>> normalizeLocation({"wght": 1001}, axes)
- {'wght': 0.0}
- """
- out = {}
- for tag, triple in axes.items():
- v = location.get(tag, triple[1])
- out[tag] = normalizeValue(v, triple, extrapolate=extrapolate)
- return out
- def supportScalar(location, support, ot=True, extrapolate=False, axisRanges=None):
- """Returns the scalar multiplier at location, for a master
- with support. If ot is True, then a peak value of zero
- for support of an axis means "axis does not participate". That
- is how OpenType Variation Font technology works.
- If extrapolate is True, axisRanges must be a dict that maps axis
- names to (axisMin, axisMax) tuples.
- >>> supportScalar({}, {})
- 1.0
- >>> supportScalar({'wght':.2}, {})
- 1.0
- >>> supportScalar({'wght':.2}, {'wght':(0,2,3)})
- 0.1
- >>> supportScalar({'wght':2.5}, {'wght':(0,2,4)})
- 0.75
- >>> supportScalar({'wght':2.5, 'wdth':0}, {'wght':(0,2,4), 'wdth':(-1,0,+1)})
- 0.75
- >>> supportScalar({'wght':2.5, 'wdth':.5}, {'wght':(0,2,4), 'wdth':(-1,0,+1)}, ot=False)
- 0.375
- >>> supportScalar({'wght':2.5, 'wdth':0}, {'wght':(0,2,4), 'wdth':(-1,0,+1)})
- 0.75
- >>> supportScalar({'wght':2.5, 'wdth':.5}, {'wght':(0,2,4), 'wdth':(-1,0,+1)})
- 0.75
- >>> supportScalar({'wght':3}, {'wght':(0,1,2)}, extrapolate=True, axisRanges={'wght':(0, 2)})
- -1.0
- >>> supportScalar({'wght':-1}, {'wght':(0,1,2)}, extrapolate=True, axisRanges={'wght':(0, 2)})
- -1.0
- >>> supportScalar({'wght':3}, {'wght':(0,2,2)}, extrapolate=True, axisRanges={'wght':(0, 2)})
- 1.5
- >>> supportScalar({'wght':-1}, {'wght':(0,2,2)}, extrapolate=True, axisRanges={'wght':(0, 2)})
- -0.5
- """
- if extrapolate and axisRanges is None:
- raise TypeError("axisRanges must be passed when extrapolate is True")
- scalar = 1.0
- for axis, (lower, peak, upper) in support.items():
- if ot:
- # OpenType-specific case handling
- if peak == 0.0:
- continue
- if lower > peak or peak > upper:
- continue
- if lower < 0.0 and upper > 0.0:
- continue
- v = location.get(axis, 0.0)
- else:
- assert axis in location
- v = location[axis]
- if v == peak:
- continue
- if extrapolate:
- axisMin, axisMax = axisRanges[axis]
- if v < axisMin and lower <= axisMin:
- if peak <= axisMin and peak < upper:
- scalar *= (v - upper) / (peak - upper)
- continue
- elif axisMin < peak:
- scalar *= (v - lower) / (peak - lower)
- continue
- elif axisMax < v and axisMax <= upper:
- if axisMax <= peak and lower < peak:
- scalar *= (v - lower) / (peak - lower)
- continue
- elif peak < axisMax:
- scalar *= (v - upper) / (peak - upper)
- continue
- if v <= lower or upper <= v:
- scalar = 0.0
- break
- if v < peak:
- scalar *= (v - lower) / (peak - lower)
- else: # v > peak
- scalar *= (v - upper) / (peak - upper)
- return scalar
- class VariationModel(object):
- """Locations must have the base master at the origin (ie. 0).
- If the extrapolate argument is set to True, then values are extrapolated
- outside the axis range.
- >>> from pprint import pprint
- >>> locations = [ \
- {'wght':100}, \
- {'wght':-100}, \
- {'wght':-180}, \
- {'wdth':+.3}, \
- {'wght':+120,'wdth':.3}, \
- {'wght':+120,'wdth':.2}, \
- {}, \
- {'wght':+180,'wdth':.3}, \
- {'wght':+180}, \
- ]
- >>> model = VariationModel(locations, axisOrder=['wght'])
- >>> pprint(model.locations)
- [{},
- {'wght': -100},
- {'wght': -180},
- {'wght': 100},
- {'wght': 180},
- {'wdth': 0.3},
- {'wdth': 0.3, 'wght': 180},
- {'wdth': 0.3, 'wght': 120},
- {'wdth': 0.2, 'wght': 120}]
- >>> pprint(model.deltaWeights)
- [{},
- {0: 1.0},
- {0: 1.0},
- {0: 1.0},
- {0: 1.0},
- {0: 1.0},
- {0: 1.0, 4: 1.0, 5: 1.0},
- {0: 1.0, 3: 0.75, 4: 0.25, 5: 1.0, 6: 0.6666666666666666},
- {0: 1.0,
- 3: 0.75,
- 4: 0.25,
- 5: 0.6666666666666667,
- 6: 0.4444444444444445,
- 7: 0.6666666666666667}]
- """
- def __init__(self, locations, axisOrder=None, extrapolate=False):
- if len(set(tuple(sorted(l.items())) for l in locations)) != len(locations):
- raise VariationModelError("Locations must be unique.")
- self.origLocations = locations
- self.axisOrder = axisOrder if axisOrder is not None else []
- self.extrapolate = extrapolate
- self.axisRanges = self.computeAxisRanges(locations) if extrapolate else None
- locations = [{k: v for k, v in loc.items() if v != 0.0} for loc in locations]
- keyFunc = self.getMasterLocationsSortKeyFunc(
- locations, axisOrder=self.axisOrder
- )
- self.locations = sorted(locations, key=keyFunc)
- # Mapping from user's master order to our master order
- self.mapping = [self.locations.index(l) for l in locations]
- self.reverseMapping = [locations.index(l) for l in self.locations]
- self._computeMasterSupports()
- self._subModels = {}
- def getSubModel(self, items):
- if None not in items:
- return self, items
- key = tuple(v is not None for v in items)
- subModel = self._subModels.get(key)
- if subModel is None:
- subModel = VariationModel(subList(key, self.origLocations), self.axisOrder)
- self._subModels[key] = subModel
- return subModel, subList(key, items)
- @staticmethod
- def computeAxisRanges(locations):
- axisRanges = {}
- allAxes = {axis for loc in locations for axis in loc.keys()}
- for loc in locations:
- for axis in allAxes:
- value = loc.get(axis, 0)
- axisMin, axisMax = axisRanges.get(axis, (value, value))
- axisRanges[axis] = min(value, axisMin), max(value, axisMax)
- return axisRanges
- @staticmethod
- def getMasterLocationsSortKeyFunc(locations, axisOrder=[]):
- if {} not in locations:
- raise VariationModelError("Base master not found.")
- axisPoints = {}
- for loc in locations:
- if len(loc) != 1:
- continue
- axis = next(iter(loc))
- value = loc[axis]
- if axis not in axisPoints:
- axisPoints[axis] = {0.0}
- assert (
- value not in axisPoints[axis]
- ), 'Value "%s" in axisPoints["%s"] --> %s' % (value, axis, axisPoints)
- axisPoints[axis].add(value)
- def getKey(axisPoints, axisOrder):
- def sign(v):
- return -1 if v < 0 else +1 if v > 0 else 0
- def key(loc):
- rank = len(loc)
- onPointAxes = [
- axis
- for axis, value in loc.items()
- if axis in axisPoints and value in axisPoints[axis]
- ]
- orderedAxes = [axis for axis in axisOrder if axis in loc]
- orderedAxes.extend(
- [axis for axis in sorted(loc.keys()) if axis not in axisOrder]
- )
- return (
- rank, # First, order by increasing rank
- -len(onPointAxes), # Next, by decreasing number of onPoint axes
- tuple(
- axisOrder.index(axis) if axis in axisOrder else 0x10000
- for axis in orderedAxes
- ), # Next, by known axes
- tuple(orderedAxes), # Next, by all axes
- tuple(
- sign(loc[axis]) for axis in orderedAxes
- ), # Next, by signs of axis values
- tuple(
- abs(loc[axis]) for axis in orderedAxes
- ), # Next, by absolute value of axis values
- )
- return key
- ret = getKey(axisPoints, axisOrder)
- return ret
- def reorderMasters(self, master_list, mapping):
- # For changing the master data order without
- # recomputing supports and deltaWeights.
- new_list = [master_list[idx] for idx in mapping]
- self.origLocations = [self.origLocations[idx] for idx in mapping]
- locations = [
- {k: v for k, v in loc.items() if v != 0.0} for loc in self.origLocations
- ]
- self.mapping = [self.locations.index(l) for l in locations]
- self.reverseMapping = [locations.index(l) for l in self.locations]
- self._subModels = {}
- return new_list
- def _computeMasterSupports(self):
- self.supports = []
- regions = self._locationsToRegions()
- for i, region in enumerate(regions):
- locAxes = set(region.keys())
- # Walk over previous masters now
- for prev_region in regions[:i]:
- # Master with extra axes do not participte
- if set(prev_region.keys()) != locAxes:
- continue
- # If it's NOT in the current box, it does not participate
- relevant = True
- for axis, (lower, peak, upper) in region.items():
- if not (
- prev_region[axis][1] == peak
- or lower < prev_region[axis][1] < upper
- ):
- relevant = False
- break
- if not relevant:
- continue
- # Split the box for new master; split in whatever direction
- # that has largest range ratio.
- #
- # For symmetry, we actually cut across multiple axes
- # if they have the largest, equal, ratio.
- # https://github.com/fonttools/fonttools/commit/7ee81c8821671157968b097f3e55309a1faa511e#commitcomment-31054804
- bestAxes = {}
- bestRatio = -1
- for axis in prev_region.keys():
- val = prev_region[axis][1]
- assert axis in region
- lower, locV, upper = region[axis]
- newLower, newUpper = lower, upper
- if val < locV:
- newLower = val
- ratio = (val - locV) / (lower - locV)
- elif locV < val:
- newUpper = val
- ratio = (val - locV) / (upper - locV)
- else: # val == locV
- # Can't split box in this direction.
- continue
- if ratio > bestRatio:
- bestAxes = {}
- bestRatio = ratio
- if ratio == bestRatio:
- bestAxes[axis] = (newLower, locV, newUpper)
- for axis, triple in bestAxes.items():
- region[axis] = triple
- self.supports.append(region)
- self._computeDeltaWeights()
- def _locationsToRegions(self):
- locations = self.locations
- # Compute min/max across each axis, use it as total range.
- # TODO Take this as input from outside?
- minV = {}
- maxV = {}
- for l in locations:
- for k, v in l.items():
- minV[k] = min(v, minV.get(k, v))
- maxV[k] = max(v, maxV.get(k, v))
- regions = []
- for loc in locations:
- region = {}
- for axis, locV in loc.items():
- if locV > 0:
- region[axis] = (0, locV, maxV[axis])
- else:
- region[axis] = (minV[axis], locV, 0)
- regions.append(region)
- return regions
- def _computeDeltaWeights(self):
- self.deltaWeights = []
- for i, loc in enumerate(self.locations):
- deltaWeight = {}
- # Walk over previous masters now, populate deltaWeight
- for j, support in enumerate(self.supports[:i]):
- scalar = supportScalar(loc, support)
- if scalar:
- deltaWeight[j] = scalar
- self.deltaWeights.append(deltaWeight)
- def getDeltas(self, masterValues, *, round=noRound):
- assert len(masterValues) == len(self.deltaWeights)
- mapping = self.reverseMapping
- out = []
- for i, weights in enumerate(self.deltaWeights):
- delta = masterValues[mapping[i]]
- for j, weight in weights.items():
- if weight == 1:
- delta -= out[j]
- else:
- delta -= out[j] * weight
- out.append(round(delta))
- return out
- def getDeltasAndSupports(self, items, *, round=noRound):
- model, items = self.getSubModel(items)
- return model.getDeltas(items, round=round), model.supports
- def getScalars(self, loc):
- return [
- supportScalar(
- loc, support, extrapolate=self.extrapolate, axisRanges=self.axisRanges
- )
- for support in self.supports
- ]
- @staticmethod
- def interpolateFromDeltasAndScalars(deltas, scalars):
- v = None
- assert len(deltas) == len(scalars)
- for delta, scalar in zip(deltas, scalars):
- if not scalar:
- continue
- contribution = delta * scalar
- if v is None:
- v = contribution
- else:
- v += contribution
- return v
- def interpolateFromDeltas(self, loc, deltas):
- scalars = self.getScalars(loc)
- return self.interpolateFromDeltasAndScalars(deltas, scalars)
- def interpolateFromMasters(self, loc, masterValues, *, round=noRound):
- deltas = self.getDeltas(masterValues, round=round)
- return self.interpolateFromDeltas(loc, deltas)
- def interpolateFromMastersAndScalars(self, masterValues, scalars, *, round=noRound):
- deltas = self.getDeltas(masterValues, round=round)
- return self.interpolateFromDeltasAndScalars(deltas, scalars)
- def piecewiseLinearMap(v, mapping):
- keys = mapping.keys()
- if not keys:
- return v
- if v in keys:
- return mapping[v]
- k = min(keys)
- if v < k:
- return v + mapping[k] - k
- k = max(keys)
- if v > k:
- return v + mapping[k] - k
- # Interpolate
- a = max(k for k in keys if k < v)
- b = min(k for k in keys if k > v)
- va = mapping[a]
- vb = mapping[b]
- return va + (vb - va) * (v - a) / (b - a)
- def main(args=None):
- """Normalize locations on a given designspace"""
- from fontTools import configLogger
- import argparse
- parser = argparse.ArgumentParser(
- "fonttools varLib.models",
- description=main.__doc__,
- )
- parser.add_argument(
- "--loglevel",
- metavar="LEVEL",
- default="INFO",
- help="Logging level (defaults to INFO)",
- )
- group = parser.add_mutually_exclusive_group(required=True)
- group.add_argument("-d", "--designspace", metavar="DESIGNSPACE", type=str)
- group.add_argument(
- "-l",
- "--locations",
- metavar="LOCATION",
- nargs="+",
- help="Master locations as comma-separate coordinates. One must be all zeros.",
- )
- args = parser.parse_args(args)
- configLogger(level=args.loglevel)
- from pprint import pprint
- if args.designspace:
- from fontTools.designspaceLib import DesignSpaceDocument
- doc = DesignSpaceDocument()
- doc.read(args.designspace)
- locs = [s.location for s in doc.sources]
- print("Original locations:")
- pprint(locs)
- doc.normalize()
- print("Normalized locations:")
- locs = [s.location for s in doc.sources]
- pprint(locs)
- else:
- axes = [chr(c) for c in range(ord("A"), ord("Z") + 1)]
- locs = [
- dict(zip(axes, (float(v) for v in s.split(",")))) for s in args.locations
- ]
- model = VariationModel(locs)
- print("Sorted locations:")
- pprint(model.locations)
- print("Supports:")
- pprint(model.supports)
- if __name__ == "__main__":
- import doctest, sys
- if len(sys.argv) > 1:
- sys.exit(main())
- sys.exit(doctest.testmod().failed)
|