It is useful to re-order the CFF2 master font list to match the sorted location order, but doing so means touching internal fields of the model, so we'll move this into the VariationModel class.
464 lines
13 KiB
Python
464 lines
13 KiB
Python
"""Variation fonts interpolation models."""
|
|
from __future__ import print_function, division, absolute_import
|
|
from fontTools.misc.py23 import *
|
|
|
|
__all__ = ['nonNone', 'allNone', 'allEqual', 'allEqualTo', 'subList',
|
|
'normalizeValue', 'normalizeLocation',
|
|
'supportScalar',
|
|
'VariationModel']
|
|
|
|
|
|
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)
|
|
else:
|
|
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)
|
|
first = next(it)
|
|
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):
|
|
"""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.6666666666666666},
|
|
{0: 1.0,
|
|
3: 0.75,
|
|
4: 0.25,
|
|
5: 0.6666666666666667,
|
|
6: 0.4444444444444445,
|
|
7: 0.6666666666666667}]
|
|
"""
|
|
|
|
def __init__(self, locations, axisOrder=[]):
|
|
self.origLocations = locations
|
|
self.axisOrder = 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, axisOrder)
|
|
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 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], (
|
|
'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 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 reorderMasters(self, master_list):
|
|
# Re-order the master item list from the original master font
|
|
# list order to the sorted location order. This puts the
|
|
# default master first, and makes building up the blend data
|
|
# simpler in some workflows, such as for CFF2 charstrrings.
|
|
new_list = [master_list[idx] for idx in self.reverseMapping]
|
|
self.origLocations = [self.origLocations[idx] for idx in self.reverseMapping]
|
|
self.mapping = self.reverseMapping = range(len(master_list))
|
|
self._subModels = {}
|
|
return new_list
|
|
|
|
def _computeMasterSupports(self, axisPoints, axisOrder):
|
|
supports = []
|
|
deltaWeights = []
|
|
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))
|
|
for i,loc in enumerate(locations):
|
|
box = {}
|
|
for axis,locV in loc.items():
|
|
if locV > 0:
|
|
box[axis] = (0, locV, maxV[axis])
|
|
else:
|
|
box[axis] = (minV[axis], locV, 0)
|
|
|
|
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,peak,upper) in box.items():
|
|
if axis not in m or not (m[axis] == peak or lower < m[axis] < 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 m.keys():
|
|
val = m[axis]
|
|
assert axis in box
|
|
lower,locV,upper = box[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 ():
|
|
box[axis] = triple
|
|
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 getDeltasAndSupports(self, items):
|
|
model, items = self.getSubModel(items)
|
|
return model.getDeltas(items), model.supports
|
|
|
|
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)
|
|
|
|
|
|
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):
|
|
from fontTools import configLogger
|
|
|
|
args = args[1:]
|
|
|
|
# TODO: allow user to configure logging via command-line options
|
|
configLogger(level="INFO")
|
|
|
|
if len(args) < 1:
|
|
print("usage: fonttools varLib.models source.designspace", file=sys.stderr)
|
|
print(" or")
|
|
print("usage: fonttools varLib.models location1 location2 ...", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
from pprint import pprint
|
|
|
|
if len(args) == 1 and args[0].endswith('.designspace'):
|
|
from fontTools.designspaceLib import DesignSpaceDocument
|
|
doc = DesignSpaceDocument()
|
|
doc.read(args[0])
|
|
locs = [s.location for s in doc.sources]
|
|
print("Original locations:")
|
|
pprint(locs)
|
|
doc.normalize()
|
|
print("Normalized locations:")
|
|
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]
|
|
|
|
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.argv))
|
|
|
|
sys.exit(doctest.testmod().failed)
|