Spaces:
Running
Running
"""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, *, validate=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} | |
""" | |
if validate: | |
assert set(location.keys()) <= set(axes.keys()), set(location.keys()) - set( | |
axes.keys() | |
) | |
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): | |
"""Return a sub-model and the items that are not None. | |
The sub-model is necessary for working with the subset | |
of items when some are None. | |
The sub-model is cached.""" | |
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) | |
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 | |
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), ( | |
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 scalars for each delta, for the given location. | |
If interpolating many master-values at the same location, | |
this function allows speed up by fetching the scalars once | |
and using them with interpolateFromMastersAndScalars().""" | |
return [ | |
supportScalar( | |
loc, support, extrapolate=self.extrapolate, axisRanges=self.axisRanges | |
) | |
for support in self.supports | |
] | |
def getMasterScalars(self, targetLocation): | |
"""Return multipliers for each master, for the given location. | |
If interpolating many master-values at the same location, | |
this function allows speed up by fetching the scalars once | |
and using them with interpolateFromValuesAndScalars(). | |
Note that the scalars used in interpolateFromMastersAndScalars(), | |
are *not* the same as the ones returned here. They are the result | |
of getScalars().""" | |
out = self.getScalars(targetLocation) | |
for i, weights in reversed(list(enumerate(self.deltaWeights))): | |
for j, weight in weights.items(): | |
out[j] -= out[i] * weight | |
out = [out[self.mapping[i]] for i in range(len(out))] | |
return out | |
def interpolateFromValuesAndScalars(values, scalars): | |
"""Interpolate from values and scalars coefficients. | |
If the values are master-values, then the scalars should be | |
fetched from getMasterScalars(). | |
If the values are deltas, then the scalars should be fetched | |
from getScalars(); in which case this is the same as | |
interpolateFromDeltasAndScalars(). | |
""" | |
v = None | |
assert len(values) == len(scalars) | |
for value, scalar in zip(values, scalars): | |
if not scalar: | |
continue | |
contribution = value * scalar | |
if v is None: | |
v = contribution | |
else: | |
v += contribution | |
return v | |
def interpolateFromDeltasAndScalars(deltas, scalars): | |
"""Interpolate from deltas and scalars fetched from getScalars().""" | |
return VariationModel.interpolateFromValuesAndScalars(deltas, scalars) | |
def interpolateFromDeltas(self, loc, deltas): | |
"""Interpolate from deltas, at location loc.""" | |
scalars = self.getScalars(loc) | |
return self.interpolateFromDeltasAndScalars(deltas, scalars) | |
def interpolateFromMasters(self, loc, masterValues, *, round=noRound): | |
"""Interpolate from master-values, at location loc.""" | |
scalars = self.getMasterScalars(loc) | |
return self.interpolateFromValuesAndScalars(masterValues, scalars) | |
def interpolateFromMastersAndScalars(self, masterValues, scalars, *, round=noRound): | |
"""Interpolate from master-values, and scalars fetched from | |
getScalars(), which is useful when you want to interpolate | |
multiple master-values with the same location.""" | |
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) | |