普通文本  |  457行  |  12.9 KB

"""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=None):
		if len(set(tuple(sorted(l.items())) for l in locations)) != len(locations):
			raise ValueError("locations must be unique")

		self.origLocations = locations
		self.axisOrder = axisOrder if axisOrder is not None else []

		locations = [{k:v for k,v in loc.items() if v != 0.} for loc in locations]
		keyFunc = self.getMasterLocationsSortKeyFunc(locations, axisOrder=self.axisOrder)
		self.locations = sorted(locations, key=keyFunc)

		# Mapping from user's master order to our master order
		self.mapping = [self.locations.index(l) for l in locations]
		self.reverseMapping = [locations.index(l) for l in self.locations]

		self._computeMasterSupports(keyFunc.axisPoints)
		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

	def reorderMasters(self, master_list, mapping):
		# For changing the master data order without
		# recomputing supports and deltaWeights.
		new_list = [master_list[idx] for idx in mapping]
		self.origLocations = [self.origLocations[idx] for idx in mapping]
		locations = [{k:v for k,v in loc.items() if v != 0.}
			     for loc in self.origLocations]
		self.mapping = [self.locations.index(l) for l in locations]
		self.reverseMapping = [locations.index(l) for l in self.locations]
		self._subModels = {}
		return new_list

	def _computeMasterSupports(self, axisPoints):
		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:")
		locs = [s.location for s in doc.sources]
		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)