• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1"""Variation fonts interpolation models."""
2
3__all__ = ['nonNone', 'allNone', 'allEqual', 'allEqualTo', 'subList',
4	   'normalizeValue', 'normalizeLocation',
5	   'supportScalar',
6	   'VariationModel']
7
8from fontTools.misc.roundTools import noRound
9from .errors import VariationModelError
10
11
12def nonNone(lst):
13	return [l for l in lst if l is not None]
14
15def allNone(lst):
16	return all(l is None for l in lst)
17
18def allEqualTo(ref, lst, mapper=None):
19	if mapper is None:
20		return all(ref == item for item in lst)
21	else:
22		mapped = mapper(ref)
23		return all(mapped == mapper(item) for item in lst)
24
25def allEqual(lst, mapper=None):
26	if not lst:
27		return True
28	it = iter(lst)
29	try:
30		first = next(it)
31	except StopIteration:
32		return True
33	return allEqualTo(first, it, mapper=mapper)
34
35def subList(truth, lst):
36	assert len(truth) == len(lst)
37	return [l for l,t in zip(lst,truth) if t]
38
39def normalizeValue(v, triple):
40	"""Normalizes value based on a min/default/max triple.
41	>>> normalizeValue(400, (100, 400, 900))
42	0.0
43	>>> normalizeValue(100, (100, 400, 900))
44	-1.0
45	>>> normalizeValue(650, (100, 400, 900))
46	0.5
47	"""
48	lower, default, upper = triple
49	if not (lower <= default <= upper):
50		raise ValueError(
51			f"Invalid axis values, must be minimum, default, maximum: "
52			f"{lower:3.3f}, {default:3.3f}, {upper:3.3f}"
53		)
54	v = max(min(v, upper), lower)
55	if v == default:
56		v = 0.
57	elif v < default:
58		v = (v - default) / (default - lower)
59	else:
60		v = (v - default) / (upper - default)
61	return v
62
63def normalizeLocation(location, axes):
64	"""Normalizes location based on axis min/default/max values from axes.
65	>>> axes = {"wght": (100, 400, 900)}
66	>>> normalizeLocation({"wght": 400}, axes)
67	{'wght': 0.0}
68	>>> normalizeLocation({"wght": 100}, axes)
69	{'wght': -1.0}
70	>>> normalizeLocation({"wght": 900}, axes)
71	{'wght': 1.0}
72	>>> normalizeLocation({"wght": 650}, axes)
73	{'wght': 0.5}
74	>>> normalizeLocation({"wght": 1000}, axes)
75	{'wght': 1.0}
76	>>> normalizeLocation({"wght": 0}, axes)
77	{'wght': -1.0}
78	>>> axes = {"wght": (0, 0, 1000)}
79	>>> normalizeLocation({"wght": 0}, axes)
80	{'wght': 0.0}
81	>>> normalizeLocation({"wght": -1}, axes)
82	{'wght': 0.0}
83	>>> normalizeLocation({"wght": 1000}, axes)
84	{'wght': 1.0}
85	>>> normalizeLocation({"wght": 500}, axes)
86	{'wght': 0.5}
87	>>> normalizeLocation({"wght": 1001}, axes)
88	{'wght': 1.0}
89	>>> axes = {"wght": (0, 1000, 1000)}
90	>>> normalizeLocation({"wght": 0}, axes)
91	{'wght': -1.0}
92	>>> normalizeLocation({"wght": -1}, axes)
93	{'wght': -1.0}
94	>>> normalizeLocation({"wght": 500}, axes)
95	{'wght': -0.5}
96	>>> normalizeLocation({"wght": 1000}, axes)
97	{'wght': 0.0}
98	>>> normalizeLocation({"wght": 1001}, axes)
99	{'wght': 0.0}
100	"""
101	out = {}
102	for tag,triple in axes.items():
103		v = location.get(tag, triple[1])
104		out[tag] = normalizeValue(v, triple)
105	return out
106
107def supportScalar(location, support, ot=True):
108	"""Returns the scalar multiplier at location, for a master
109	with support.  If ot is True, then a peak value of zero
110	for support of an axis means "axis does not participate".  That
111	is how OpenType Variation Font technology works.
112	>>> supportScalar({}, {})
113	1.0
114	>>> supportScalar({'wght':.2}, {})
115	1.0
116	>>> supportScalar({'wght':.2}, {'wght':(0,2,3)})
117	0.1
118	>>> supportScalar({'wght':2.5}, {'wght':(0,2,4)})
119	0.75
120	>>> supportScalar({'wght':2.5, 'wdth':0}, {'wght':(0,2,4), 'wdth':(-1,0,+1)})
121	0.75
122	>>> supportScalar({'wght':2.5, 'wdth':.5}, {'wght':(0,2,4), 'wdth':(-1,0,+1)}, ot=False)
123	0.375
124	>>> supportScalar({'wght':2.5, 'wdth':0}, {'wght':(0,2,4), 'wdth':(-1,0,+1)})
125	0.75
126	>>> supportScalar({'wght':2.5, 'wdth':.5}, {'wght':(0,2,4), 'wdth':(-1,0,+1)})
127	0.75
128	"""
129	scalar = 1.
130	for axis,(lower,peak,upper) in support.items():
131		if ot:
132			# OpenType-specific case handling
133			if peak == 0.:
134				continue
135			if lower > peak or peak > upper:
136				continue
137			if lower < 0. and upper > 0.:
138				continue
139			v = location.get(axis, 0.)
140		else:
141			assert axis in location
142			v = location[axis]
143		if v == peak:
144			continue
145		if v <= lower or upper <= v:
146			scalar = 0.
147			break
148		if v < peak:
149			scalar *= (v - lower) / (peak - lower)
150		else: # v > peak
151			scalar *= (v - upper) / (peak - upper)
152	return scalar
153
154
155class VariationModel(object):
156
157	"""
158	Locations must be in normalized space.  Ie. base master
159	is at origin (0).
160	>>> from pprint import pprint
161	>>> locations = [ \
162	{'wght':100}, \
163	{'wght':-100}, \
164	{'wght':-180}, \
165	{'wdth':+.3}, \
166	{'wght':+120,'wdth':.3}, \
167	{'wght':+120,'wdth':.2}, \
168	{}, \
169	{'wght':+180,'wdth':.3}, \
170	{'wght':+180}, \
171	]
172	>>> model = VariationModel(locations, axisOrder=['wght'])
173	>>> pprint(model.locations)
174	[{},
175	 {'wght': -100},
176	 {'wght': -180},
177	 {'wght': 100},
178	 {'wght': 180},
179	 {'wdth': 0.3},
180	 {'wdth': 0.3, 'wght': 180},
181	 {'wdth': 0.3, 'wght': 120},
182	 {'wdth': 0.2, 'wght': 120}]
183	>>> pprint(model.deltaWeights)
184	[{},
185	 {0: 1.0},
186	 {0: 1.0},
187	 {0: 1.0},
188	 {0: 1.0},
189	 {0: 1.0},
190	 {0: 1.0, 4: 1.0, 5: 1.0},
191	 {0: 1.0, 3: 0.75, 4: 0.25, 5: 1.0, 6: 0.6666666666666666},
192	 {0: 1.0,
193	  3: 0.75,
194	  4: 0.25,
195	  5: 0.6666666666666667,
196	  6: 0.4444444444444445,
197	  7: 0.6666666666666667}]
198	"""
199
200	def __init__(self, locations, axisOrder=None):
201		if len(set(tuple(sorted(l.items())) for l in locations)) != len(locations):
202			raise VariationModelError("Locations must be unique.")
203
204		self.origLocations = locations
205		self.axisOrder = axisOrder if axisOrder is not None else []
206
207		locations = [{k:v for k,v in loc.items() if v != 0.} for loc in locations]
208		keyFunc = self.getMasterLocationsSortKeyFunc(locations, axisOrder=self.axisOrder)
209		self.locations = sorted(locations, key=keyFunc)
210
211		# Mapping from user's master order to our master order
212		self.mapping = [self.locations.index(l) for l in locations]
213		self.reverseMapping = [locations.index(l) for l in self.locations]
214
215		self._computeMasterSupports(keyFunc.axisPoints)
216		self._subModels = {}
217
218	def getSubModel(self, items):
219		if None not in items:
220			return self, items
221		key = tuple(v is not None for v in items)
222		subModel = self._subModels.get(key)
223		if subModel is None:
224			subModel = VariationModel(subList(key, self.origLocations), self.axisOrder)
225			self._subModels[key] = subModel
226		return subModel, subList(key, items)
227
228	@staticmethod
229	def getMasterLocationsSortKeyFunc(locations, axisOrder=[]):
230		if {} not in locations:
231			raise VariationModelError("Base master not found.")
232		axisPoints = {}
233		for loc in locations:
234			if len(loc) != 1:
235				continue
236			axis = next(iter(loc))
237			value = loc[axis]
238			if axis not in axisPoints:
239				axisPoints[axis] = {0.}
240			assert value not in axisPoints[axis], (
241				'Value "%s" in axisPoints["%s"] -->  %s' % (value, axis, axisPoints)
242			)
243			axisPoints[axis].add(value)
244
245		def getKey(axisPoints, axisOrder):
246			def sign(v):
247				return -1 if v < 0 else +1 if v > 0 else 0
248			def key(loc):
249				rank = len(loc)
250				onPointAxes = [
251					axis for axis, value in loc.items()
252					if axis in axisPoints
253					and value in axisPoints[axis]
254				]
255				orderedAxes = [axis for axis in axisOrder if axis in loc]
256				orderedAxes.extend([axis for axis in sorted(loc.keys()) if axis not in axisOrder])
257				return (
258					rank, # First, order by increasing rank
259					-len(onPointAxes), # Next, by decreasing number of onPoint axes
260					tuple(axisOrder.index(axis) if axis in axisOrder else 0x10000 for axis in orderedAxes), # Next, by known axes
261					tuple(orderedAxes), # Next, by all axes
262					tuple(sign(loc[axis]) for axis in orderedAxes), # Next, by signs of axis values
263					tuple(abs(loc[axis]) for axis in orderedAxes), # Next, by absolute value of axis values
264				)
265			return key
266
267		ret = getKey(axisPoints, axisOrder)
268		ret.axisPoints = axisPoints
269		return ret
270
271	def reorderMasters(self, master_list, mapping):
272		# For changing the master data order without
273		# recomputing supports and deltaWeights.
274		new_list = [master_list[idx] for idx in mapping]
275		self.origLocations = [self.origLocations[idx] for idx in mapping]
276		locations = [{k:v for k,v in loc.items() if v != 0.}
277			     for loc in self.origLocations]
278		self.mapping = [self.locations.index(l) for l in locations]
279		self.reverseMapping = [locations.index(l) for l in self.locations]
280		self._subModels = {}
281		return new_list
282
283	def _computeMasterSupports(self, axisPoints):
284		supports = []
285		regions = self._locationsToRegions()
286		for i,region in enumerate(regions):
287			locAxes = set(region.keys())
288			# Walk over previous masters now
289			for j,prev_region in enumerate(regions[:i]):
290				# Master with extra axes do not participte
291				if not set(prev_region.keys()).issubset(locAxes):
292					continue
293				# If it's NOT in the current box, it does not participate
294				relevant = True
295				for axis, (lower,peak,upper) in region.items():
296					if axis not in prev_region or not (prev_region[axis][1] == peak or lower < prev_region[axis][1] < upper):
297						relevant = False
298						break
299				if not relevant:
300					continue
301
302				# Split the box for new master; split in whatever direction
303				# that has largest range ratio.
304				#
305				# For symmetry, we actually cut across multiple axes
306				# if they have the largest, equal, ratio.
307				# https://github.com/fonttools/fonttools/commit/7ee81c8821671157968b097f3e55309a1faa511e#commitcomment-31054804
308
309				bestAxes = {}
310				bestRatio = -1
311				for axis in prev_region.keys():
312					val = prev_region[axis][1]
313					assert axis in region
314					lower,locV,upper = region[axis]
315					newLower, newUpper = lower, upper
316					if val < locV:
317						newLower = val
318						ratio = (val - locV) / (lower - locV)
319					elif locV < val:
320						newUpper = val
321						ratio = (val - locV) / (upper - locV)
322					else: # val == locV
323						# Can't split box in this direction.
324						continue
325					if ratio > bestRatio:
326						bestAxes = {}
327						bestRatio = ratio
328					if ratio == bestRatio:
329						bestAxes[axis] = (newLower, locV, newUpper)
330
331				for axis,triple in bestAxes.items ():
332					region[axis] = triple
333			supports.append(region)
334		self.supports = supports
335		self._computeDeltaWeights()
336
337	def _locationsToRegions(self):
338		locations = self.locations
339		# Compute min/max across each axis, use it as total range.
340		# TODO Take this as input from outside?
341		minV = {}
342		maxV = {}
343		for l in locations:
344			for k,v in l.items():
345				minV[k] = min(v, minV.get(k, v))
346				maxV[k] = max(v, maxV.get(k, v))
347
348		regions = []
349		for i,loc in enumerate(locations):
350			region = {}
351			for axis,locV in loc.items():
352				if locV > 0:
353					region[axis] = (0, locV, maxV[axis])
354				else:
355					region[axis] = (minV[axis], locV, 0)
356			regions.append(region)
357		return regions
358
359	def _computeDeltaWeights(self):
360		deltaWeights = []
361		for i,loc in enumerate(self.locations):
362			deltaWeight = {}
363			# Walk over previous masters now, populate deltaWeight
364			for j,m in enumerate(self.locations[:i]):
365				scalar = supportScalar(loc, self.supports[j])
366				if scalar:
367					deltaWeight[j] = scalar
368			deltaWeights.append(deltaWeight)
369		self.deltaWeights = deltaWeights
370
371	def getDeltas(self, masterValues, *, round=noRound):
372		assert len(masterValues) == len(self.deltaWeights)
373		mapping = self.reverseMapping
374		out = []
375		for i,weights in enumerate(self.deltaWeights):
376			delta = masterValues[mapping[i]]
377			for j,weight in weights.items():
378				delta -= out[j] * weight
379			out.append(round(delta))
380		return out
381
382	def getDeltasAndSupports(self, items, *, round=noRound):
383		model, items = self.getSubModel(items)
384		return model.getDeltas(items, round=round), model.supports
385
386	def getScalars(self, loc):
387		return [supportScalar(loc, support) for support in self.supports]
388
389	@staticmethod
390	def interpolateFromDeltasAndScalars(deltas, scalars):
391		v = None
392		assert len(deltas) == len(scalars)
393		for delta, scalar in zip(deltas, scalars):
394			if not scalar: continue
395			contribution = delta * scalar
396			if v is None:
397				v = contribution
398			else:
399				v += contribution
400		return v
401
402	def interpolateFromDeltas(self, loc, deltas):
403		scalars = self.getScalars(loc)
404		return self.interpolateFromDeltasAndScalars(deltas, scalars)
405
406	def interpolateFromMasters(self, loc, masterValues, *, round=noRound):
407		deltas = self.getDeltas(masterValues, round=round)
408		return self.interpolateFromDeltas(loc, deltas)
409
410	def interpolateFromMastersAndScalars(self, masterValues, scalars, *, round=noRound):
411		deltas = self.getDeltas(masterValues, round=round)
412		return self.interpolateFromDeltasAndScalars(deltas, scalars)
413
414
415def piecewiseLinearMap(v, mapping):
416	keys = mapping.keys()
417	if not keys:
418		return v
419	if v in keys:
420		return mapping[v]
421	k = min(keys)
422	if v < k:
423		return v + mapping[k] - k
424	k = max(keys)
425	if v > k:
426		return v + mapping[k] - k
427	# Interpolate
428	a = max(k for k in keys if k < v)
429	b = min(k for k in keys if k > v)
430	va = mapping[a]
431	vb = mapping[b]
432	return va + (vb - va) * (v - a) / (b - a)
433
434
435def main(args=None):
436	"""Normalize locations on a given designspace"""
437	from fontTools import configLogger
438	import argparse
439
440	parser = argparse.ArgumentParser(
441		"fonttools varLib.models",
442		description=main.__doc__,
443	)
444	parser.add_argument('--loglevel', metavar='LEVEL', default="INFO",
445		help="Logging level (defaults to INFO)")
446
447	group = parser.add_mutually_exclusive_group(required=True)
448	group.add_argument('-d', '--designspace',metavar="DESIGNSPACE",type=str)
449	group.add_argument('-l', '--locations', metavar='LOCATION', nargs='+',
450		help="Master locations as comma-separate coordinates. One must be all zeros.")
451
452	args = parser.parse_args(args)
453
454	configLogger(level=args.loglevel)
455	from pprint import pprint
456
457	if args.designspace:
458		from fontTools.designspaceLib import DesignSpaceDocument
459		doc = DesignSpaceDocument()
460		doc.read(args.designspace)
461		locs = [s.location for s in doc.sources]
462		print("Original locations:")
463		pprint(locs)
464		doc.normalize()
465		print("Normalized locations:")
466		locs = [s.location for s in doc.sources]
467		pprint(locs)
468	else:
469		axes = [chr(c) for c in range(ord('A'), ord('Z')+1)]
470		locs = [dict(zip(axes, (float(v) for v in s.split(',')))) for s in args.locations]
471
472	model = VariationModel(locs)
473	print("Sorted locations:")
474	pprint(model.locations)
475	print("Supports:")
476	pprint(model.supports)
477
478if __name__ == "__main__":
479	import doctest, sys
480
481	if len(sys.argv) > 1:
482		sys.exit(main())
483
484	sys.exit(doctest.testmod().failed)
485