317 lines
9.0 KiB
Python
317 lines
9.0 KiB
Python
"""Variation fonts interpolation models."""
|
|
from __future__ import print_function, division, absolute_import
|
|
from fontTools.misc.py23 import *
|
|
|
|
__all__ = ['normalizeValue', 'normalizeLocation', 'supportScalar', 'VariationModel']
|
|
|
|
def normalizeValue(v, triple):
|
|
"""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
|
|
assert lower <= default <= upper, "invalid axis values: %3.3f, %3.3f %3.3f"%(lower, default, upper)
|
|
v = max(min(v, upper), lower)
|
|
if v == default:
|
|
v = 0.
|
|
elif v < default:
|
|
v = (v - default) / (default - lower)
|
|
else:
|
|
v = (v - default) / (upper - default)
|
|
return v
|
|
|
|
def normalizeLocation(location, axes):
|
|
"""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)
|
|
return out
|
|
|
|
def supportScalar(location, support, ot=True):
|
|
"""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.
|
|
>>> 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
|
|
"""
|
|
scalar = 1.
|
|
for axis,(lower,peak,upper) in support.items():
|
|
if ot:
|
|
# OpenType-specific case handling
|
|
if peak == 0.:
|
|
continue
|
|
if lower > peak or peak > upper:
|
|
continue
|
|
if lower < 0. and upper > 0.:
|
|
continue
|
|
v = location.get(axis, 0.)
|
|
else:
|
|
assert axis in location
|
|
v = location[axis]
|
|
if v == peak:
|
|
continue
|
|
if v <= lower or upper <= v:
|
|
scalar = 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 be in normalized space. Ie. base master
|
|
is at origin (0).
|
|
>>> 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.25},
|
|
{0: 1.0,
|
|
3: 0.75,
|
|
4: 0.25,
|
|
5: 0.6666666666666667,
|
|
6: 0.16666666666666669,
|
|
7: 0.6666666666666667}]
|
|
"""
|
|
|
|
def __init__(self, locations, axisOrder=[]):
|
|
locations = [{k:v for k,v in loc.items() if v != 0.} for loc in locations]
|
|
keyFunc = self.getMasterLocationsSortKeyFunc(locations, axisOrder=axisOrder)
|
|
axisPoints = keyFunc.axisPoints
|
|
self.locations = sorted(locations, key=keyFunc)
|
|
# TODO Assert that locations are unique.
|
|
self.mapping = [self.locations.index(l) for l in locations] # Mapping from user's master order to our master order
|
|
self.reverseMapping = [locations.index(l) for l in self.locations] # Reverse of above
|
|
|
|
self._computeMasterSupports(axisPoints)
|
|
|
|
@staticmethod
|
|
def getMasterLocationsSortKeyFunc(locations, axisOrder=[]):
|
|
assert {} in locations, "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.}
|
|
assert value not in axisPoints[axis]
|
|
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 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)
|
|
ret.axisPoints = axisPoints
|
|
return ret
|
|
|
|
@staticmethod
|
|
def lowerBound(value, lst):
|
|
if any(v < value for v in lst):
|
|
return max(v for v in lst if v < value)
|
|
else:
|
|
return value
|
|
@staticmethod
|
|
def upperBound(value, lst):
|
|
if any(v > value for v in lst):
|
|
return min(v for v in lst if v > value)
|
|
else:
|
|
return value
|
|
|
|
def _computeMasterSupports(self, axisPoints):
|
|
supports = []
|
|
deltaWeights = []
|
|
locations = self.locations
|
|
for i,loc in enumerate(locations):
|
|
box = {}
|
|
|
|
# Account for axisPoints first
|
|
for axis,values in axisPoints.items():
|
|
if not axis in loc:
|
|
continue
|
|
locV = loc[axis]
|
|
box[axis] = (self.lowerBound(locV, values), locV, self.upperBound(locV, values))
|
|
|
|
locAxes = set(loc.keys())
|
|
# Walk over previous masters now
|
|
for j,m in enumerate(locations[:i]):
|
|
# Master with extra axes do not participte
|
|
if not set(m.keys()).issubset(locAxes):
|
|
continue
|
|
# If it's NOT in the current box, it does not participate
|
|
relevant = True
|
|
for axis, (lower,_,upper) in box.items():
|
|
if axis in m and not (lower < m[axis] < upper):
|
|
relevant = False
|
|
break
|
|
if not relevant:
|
|
continue
|
|
# Split the box for new master
|
|
for axis,val in m.items():
|
|
assert axis in box
|
|
lower,locV,upper = box[axis]
|
|
if val < locV:
|
|
lower = val
|
|
elif locV < val:
|
|
upper = val
|
|
box[axis] = (lower,locV,upper)
|
|
supports.append(box)
|
|
|
|
deltaWeight = {}
|
|
# Walk over previous masters now, populate deltaWeight
|
|
for j,m in enumerate(locations[:i]):
|
|
scalar = supportScalar(loc, supports[j])
|
|
if scalar:
|
|
deltaWeight[j] = scalar
|
|
deltaWeights.append(deltaWeight)
|
|
|
|
self.supports = supports
|
|
self.deltaWeights = deltaWeights
|
|
|
|
def getDeltas(self, masterValues):
|
|
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():
|
|
delta -= out[j] * weight
|
|
out.append(delta)
|
|
return out
|
|
|
|
def getScalars(self, loc):
|
|
return [supportScalar(loc, support) for support in self.supports]
|
|
|
|
@staticmethod
|
|
def interpolateFromDeltasAndScalars(deltas, scalars):
|
|
v = None
|
|
assert len(deltas) == len(scalars)
|
|
for i,(delta,scalar) in enumerate(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):
|
|
deltas = self.getDeltas(masterValues)
|
|
return self.interpolateFromDeltas(loc, deltas)
|
|
|
|
def interpolateFromMastersAndScalars(self, masterValues, scalars):
|
|
deltas = self.getDeltas(masterValues)
|
|
return self.interpolateFromDeltasAndScalars(deltas, scalars)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import doctest, sys
|
|
sys.exit(doctest.testmod().failed)
|