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