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