1"""
2Module for dealing with 'gvar'-style font variations, also known as run-time
3interpolation.
4
5The ideas here are very similar to MutatorMath.  There is even code to read
6MutatorMath .designspace files in the varLib.designspace module.
7
8For now, if you run this file on a designspace file, it tries to find
9ttf-interpolatable files for the masters and build a variable-font from
10them.  Such ttf-interpolatable and designspace files can be generated from
11a Glyphs source, eg., using noto-source as an example:
12
13	$ fontmake -o ttf-interpolatable -g NotoSansArabic-MM.glyphs
14
15Then you can make a variable-font this way:
16
17	$ fonttools varLib master_ufo/NotoSansArabic.designspace
18
19API *will* change in near future.
20"""
21from __future__ import print_function, division, absolute_import
22from __future__ import unicode_literals
23from fontTools.misc.py23 import *
24from fontTools.misc.fixedTools import otRound
25from fontTools.misc.arrayTools import Vector
26from fontTools.ttLib import TTFont, newTable, TTLibError
27from fontTools.ttLib.tables._n_a_m_e import NameRecord
28from fontTools.ttLib.tables._f_v_a_r import Axis, NamedInstance
29from fontTools.ttLib.tables._g_l_y_f import GlyphCoordinates
30from fontTools.ttLib.tables.ttProgram import Program
31from fontTools.ttLib.tables.TupleVariation import TupleVariation
32from fontTools.ttLib.tables import otTables as ot
33from fontTools.ttLib.tables.otBase import OTTableWriter
34from fontTools.varLib import builder, models, varStore
35from fontTools.varLib.merger import VariationMerger
36from fontTools.varLib.mvar import MVAR_ENTRIES
37from fontTools.varLib.iup import iup_delta_optimize
38from fontTools.varLib.featureVars import addFeatureVariations
39from fontTools.designspaceLib import DesignSpaceDocument, AxisDescriptor
40from collections import OrderedDict, namedtuple
41import os.path
42import logging
43from copy import deepcopy
44from pprint import pformat
45
46log = logging.getLogger("fontTools.varLib")
47
48
49class VarLibError(Exception):
50	pass
51
52#
53# Creation routines
54#
55
56def _add_fvar(font, axes, instances):
57	"""
58	Add 'fvar' table to font.
59
60	axes is an ordered dictionary of DesignspaceAxis objects.
61
62	instances is list of dictionary objects with 'location', 'stylename',
63	and possibly 'postscriptfontname' entries.
64	"""
65
66	assert axes
67	assert isinstance(axes, OrderedDict)
68
69	log.info("Generating fvar")
70
71	fvar = newTable('fvar')
72	nameTable = font['name']
73
74	for a in axes.values():
75		axis = Axis()
76		axis.axisTag = Tag(a.tag)
77		# TODO Skip axes that have no variation.
78		axis.minValue, axis.defaultValue, axis.maxValue = a.minimum, a.default, a.maximum
79		axis.axisNameID = nameTable.addMultilingualName(a.labelNames, font)
80		axis.flags = int(a.hidden)
81		fvar.axes.append(axis)
82
83	for instance in instances:
84		coordinates = instance.location
85
86		if "en" not in instance.localisedStyleName:
87			assert instance.styleName
88			localisedStyleName = dict(instance.localisedStyleName)
89			localisedStyleName["en"] = tounicode(instance.styleName)
90		else:
91			localisedStyleName = instance.localisedStyleName
92
93		psname = instance.postScriptFontName
94
95		inst = NamedInstance()
96		inst.subfamilyNameID = nameTable.addMultilingualName(localisedStyleName)
97		if psname is not None:
98			psname = tounicode(psname)
99			inst.postscriptNameID = nameTable.addName(psname)
100		inst.coordinates = {axes[k].tag:axes[k].map_backward(v) for k,v in coordinates.items()}
101		#inst.coordinates = {axes[k].tag:v for k,v in coordinates.items()}
102		fvar.instances.append(inst)
103
104	assert "fvar" not in font
105	font['fvar'] = fvar
106
107	return fvar
108
109def _add_avar(font, axes):
110	"""
111	Add 'avar' table to font.
112
113	axes is an ordered dictionary of AxisDescriptor objects.
114	"""
115
116	assert axes
117	assert isinstance(axes, OrderedDict)
118
119	log.info("Generating avar")
120
121	avar = newTable('avar')
122
123	interesting = False
124	for axis in axes.values():
125		# Currently, some rasterizers require that the default value maps
126		# (-1 to -1, 0 to 0, and 1 to 1) be present for all the segment
127		# maps, even when the default normalization mapping for the axis
128		# was not modified.
129		# https://github.com/googlei18n/fontmake/issues/295
130		# https://github.com/fonttools/fonttools/issues/1011
131		# TODO(anthrotype) revert this (and 19c4b37) when issue is fixed
132		curve = avar.segments[axis.tag] = {-1.0: -1.0, 0.0: 0.0, 1.0: 1.0}
133		if not axis.map:
134			continue
135
136		items = sorted(axis.map)
137		keys = [item[0] for item in items]
138		vals = [item[1] for item in items]
139
140		# Current avar requirements.  We don't have to enforce
141		# these on the designer and can deduce some ourselves,
142		# but for now just enforce them.
143		assert axis.minimum == min(keys)
144		assert axis.maximum == max(keys)
145		assert axis.default in keys
146		# No duplicates
147		assert len(set(keys)) == len(keys)
148		assert len(set(vals)) == len(vals)
149		# Ascending values
150		assert sorted(vals) == vals
151
152		keys_triple = (axis.minimum, axis.default, axis.maximum)
153		vals_triple = tuple(axis.map_forward(v) for v in keys_triple)
154
155		keys = [models.normalizeValue(v, keys_triple) for v in keys]
156		vals = [models.normalizeValue(v, vals_triple) for v in vals]
157
158		if all(k == v for k, v in zip(keys, vals)):
159			continue
160		interesting = True
161
162		curve.update(zip(keys, vals))
163
164		assert 0.0 in curve and curve[0.0] == 0.0
165		assert -1.0 not in curve or curve[-1.0] == -1.0
166		assert +1.0 not in curve or curve[+1.0] == +1.0
167		# curve.update({-1.0: -1.0, 0.0: 0.0, 1.0: 1.0})
168
169	assert "avar" not in font
170	if not interesting:
171		log.info("No need for avar")
172		avar = None
173	else:
174		font['avar'] = avar
175
176	return avar
177
178def _add_stat(font, axes):
179	# for now we just get the axis tags and nameIDs from the fvar,
180	# so we can reuse the same nameIDs which were defined in there.
181	# TODO make use of 'axes' once it adds style attributes info:
182	# https://github.com/LettError/designSpaceDocument/issues/8
183
184	if "STAT" in font:
185		return
186
187	fvarTable = font['fvar']
188
189	STAT = font["STAT"] = newTable('STAT')
190	stat = STAT.table = ot.STAT()
191	stat.Version = 0x00010001
192
193	axisRecords = []
194	for i, a in enumerate(fvarTable.axes):
195		axis = ot.AxisRecord()
196		axis.AxisTag = Tag(a.axisTag)
197		axis.AxisNameID = a.axisNameID
198		axis.AxisOrdering = i
199		axisRecords.append(axis)
200
201	axisRecordArray = ot.AxisRecordArray()
202	axisRecordArray.Axis = axisRecords
203	# XXX these should not be hard-coded but computed automatically
204	stat.DesignAxisRecordSize = 8
205	stat.DesignAxisCount = len(axisRecords)
206	stat.DesignAxisRecord = axisRecordArray
207
208	# for the elided fallback name, we default to the base style name.
209	# TODO make this user-configurable via designspace document
210	stat.ElidedFallbackNameID = 2
211
212
213def _get_phantom_points(font, glyphName, defaultVerticalOrigin=None):
214	glyf = font["glyf"]
215	glyph = glyf[glyphName]
216	horizontalAdvanceWidth, leftSideBearing = font["hmtx"].metrics[glyphName]
217	if not hasattr(glyph, 'xMin'):
218		glyph.recalcBounds(glyf)
219	leftSideX = glyph.xMin - leftSideBearing
220	rightSideX = leftSideX + horizontalAdvanceWidth
221	if "vmtx" in font:
222		verticalAdvanceWidth, topSideBearing = font["vmtx"].metrics[glyphName]
223		topSideY = topSideBearing + glyph.yMax
224	else:
225		# without vmtx, use ascent as vertical origin and UPEM as vertical advance
226		# like HarfBuzz does
227		verticalAdvanceWidth = font["head"].unitsPerEm
228		try:
229			topSideY = font["hhea"].ascent
230		except KeyError:
231			# sparse masters may not contain an hhea table; use the ascent
232			# of the default master as the vertical origin
233			assert defaultVerticalOrigin is not None
234			topSideY = defaultVerticalOrigin
235	bottomSideY = topSideY - verticalAdvanceWidth
236	return [
237		(leftSideX, 0),
238		(rightSideX, 0),
239		(0, topSideY),
240		(0, bottomSideY),
241	]
242
243
244# TODO Move to glyf or gvar table proper
245def _GetCoordinates(font, glyphName, defaultVerticalOrigin=None):
246	"""font, glyphName --> glyph coordinates as expected by "gvar" table
247
248	The result includes four "phantom points" for the glyph metrics,
249	as mandated by the "gvar" spec.
250	"""
251	glyf = font["glyf"]
252	if glyphName not in glyf.glyphs: return None
253	glyph = glyf[glyphName]
254	if glyph.isComposite():
255		coord = GlyphCoordinates([(getattr(c, 'x', 0),getattr(c, 'y', 0)) for c in glyph.components])
256		control = (glyph.numberOfContours,[c.glyphName for c in glyph.components])
257	else:
258		allData = glyph.getCoordinates(glyf)
259		coord = allData[0]
260		control = (glyph.numberOfContours,)+allData[1:]
261
262	# Add phantom points for (left, right, top, bottom) positions.
263	phantomPoints = _get_phantom_points(font, glyphName, defaultVerticalOrigin)
264	coord = coord.copy()
265	coord.extend(phantomPoints)
266
267	return coord, control
268
269# TODO Move to glyf or gvar table proper
270def _SetCoordinates(font, glyphName, coord):
271	glyf = font["glyf"]
272	assert glyphName in glyf.glyphs
273	glyph = glyf[glyphName]
274
275	# Handle phantom points for (left, right, top, bottom) positions.
276	assert len(coord) >= 4
277	if not hasattr(glyph, 'xMin'):
278		glyph.recalcBounds(glyf)
279	leftSideX = coord[-4][0]
280	rightSideX = coord[-3][0]
281	topSideY = coord[-2][1]
282	bottomSideY = coord[-1][1]
283
284	for _ in range(4):
285		del coord[-1]
286
287	if glyph.isComposite():
288		assert len(coord) == len(glyph.components)
289		for p,comp in zip(coord, glyph.components):
290			if hasattr(comp, 'x'):
291				comp.x,comp.y = p
292	elif glyph.numberOfContours is 0:
293		assert len(coord) == 0
294	else:
295		assert len(coord) == len(glyph.coordinates)
296		glyph.coordinates = coord
297
298	glyph.recalcBounds(glyf)
299
300	horizontalAdvanceWidth = otRound(rightSideX - leftSideX)
301	if horizontalAdvanceWidth < 0:
302		# unlikely, but it can happen, see:
303		# https://github.com/fonttools/fonttools/pull/1198
304		horizontalAdvanceWidth = 0
305	leftSideBearing = otRound(glyph.xMin - leftSideX)
306	# XXX Handle vertical
307	font["hmtx"].metrics[glyphName] = horizontalAdvanceWidth, leftSideBearing
308
309def _add_gvar(font, masterModel, master_ttfs, tolerance=0.5, optimize=True):
310
311	assert tolerance >= 0
312
313	log.info("Generating gvar")
314	assert "gvar" not in font
315	gvar = font["gvar"] = newTable('gvar')
316	gvar.version = 1
317	gvar.reserved = 0
318	gvar.variations = {}
319
320	glyf = font['glyf']
321
322	# use hhea.ascent of base master as default vertical origin when vmtx is missing
323	defaultVerticalOrigin = font['hhea'].ascent
324	for glyph in font.getGlyphOrder():
325
326		isComposite = glyf[glyph].isComposite()
327
328		allData = [
329			_GetCoordinates(m, glyph, defaultVerticalOrigin=defaultVerticalOrigin)
330			for m in master_ttfs
331		]
332		model, allData = masterModel.getSubModel(allData)
333
334		allCoords = [d[0] for d in allData]
335		allControls = [d[1] for d in allData]
336		control = allControls[0]
337		if not models.allEqual(allControls):
338			log.warning("glyph %s has incompatible masters; skipping" % glyph)
339			continue
340		del allControls
341
342		# Update gvar
343		gvar.variations[glyph] = []
344		deltas = model.getDeltas(allCoords)
345		supports = model.supports
346		assert len(deltas) == len(supports)
347
348		# Prepare for IUP optimization
349		origCoords = deltas[0]
350		endPts = control[1] if control[0] >= 1 else list(range(len(control[1])))
351
352		for i,(delta,support) in enumerate(zip(deltas[1:], supports[1:])):
353			if all(abs(v) <= tolerance for v in delta.array) and not isComposite:
354				continue
355			var = TupleVariation(support, delta)
356			if optimize:
357				delta_opt = iup_delta_optimize(delta, origCoords, endPts, tolerance=tolerance)
358
359				if None in delta_opt:
360					"""In composite glyphs, there should be one 0 entry
361					to make sure the gvar entry is written to the font.
362
363					This is to work around an issue with macOS 10.14 and can be
364					removed once the behaviour of macOS is changed.
365
366					https://github.com/fonttools/fonttools/issues/1381
367					"""
368					if all(d is None for d in delta_opt):
369						delta_opt = [(0, 0)] + [None] * (len(delta_opt) - 1)
370					# Use "optimized" version only if smaller...
371					var_opt = TupleVariation(support, delta_opt)
372
373					axis_tags = sorted(support.keys()) # Shouldn't matter that this is different from fvar...?
374					tupleData, auxData, _ = var.compile(axis_tags, [], None)
375					unoptimized_len = len(tupleData) + len(auxData)
376					tupleData, auxData, _ = var_opt.compile(axis_tags, [], None)
377					optimized_len = len(tupleData) + len(auxData)
378
379					if optimized_len < unoptimized_len:
380						var = var_opt
381
382			gvar.variations[glyph].append(var)
383
384def _remove_TTHinting(font):
385	for tag in ("cvar", "cvt ", "fpgm", "prep"):
386		if tag in font:
387			del font[tag]
388	for attr in ("maxTwilightPoints", "maxStorage", "maxFunctionDefs", "maxInstructionDefs", "maxStackElements", "maxSizeOfInstructions"):
389		setattr(font["maxp"], attr, 0)
390	font["maxp"].maxZones = 1
391	font["glyf"].removeHinting()
392	# TODO: Modify gasp table to deactivate gridfitting for all ranges?
393
394def _merge_TTHinting(font, masterModel, master_ttfs, tolerance=0.5):
395
396	log.info("Merging TT hinting")
397	assert "cvar" not in font
398
399	# Check that the existing hinting is compatible
400
401	# fpgm and prep table
402
403	for tag in ("fpgm", "prep"):
404		all_pgms = [m[tag].program for m in master_ttfs if tag in m]
405		if len(all_pgms) == 0:
406			continue
407		if tag in font:
408			font_pgm = font[tag].program
409		else:
410			font_pgm = Program()
411		if any(pgm != font_pgm for pgm in all_pgms):
412			log.warning("Masters have incompatible %s tables, hinting is discarded." % tag)
413			_remove_TTHinting(font)
414			return
415
416	# glyf table
417
418	for name, glyph in font["glyf"].glyphs.items():
419		all_pgms = [
420			m["glyf"][name].program
421			for m in master_ttfs
422			if name in m['glyf'] and hasattr(m["glyf"][name], "program")
423		]
424		if not any(all_pgms):
425			continue
426		glyph.expand(font["glyf"])
427		if hasattr(glyph, "program"):
428			font_pgm = glyph.program
429		else:
430			font_pgm = Program()
431		if any(pgm != font_pgm for pgm in all_pgms if pgm):
432			log.warning("Masters have incompatible glyph programs in glyph '%s', hinting is discarded." % name)
433			# TODO Only drop hinting from this glyph.
434			_remove_TTHinting(font)
435			return
436
437	# cvt table
438
439	all_cvs = [Vector(m["cvt "].values) if 'cvt ' in m else None
440		   for m in master_ttfs]
441
442	nonNone_cvs = models.nonNone(all_cvs)
443	if not nonNone_cvs:
444		# There is no cvt table to make a cvar table from, we're done here.
445		return
446
447	if not models.allEqual(len(c) for c in nonNone_cvs):
448		log.warning("Masters have incompatible cvt tables, hinting is discarded.")
449		_remove_TTHinting(font)
450		return
451
452	# We can build the cvar table now.
453
454	cvar = font["cvar"] = newTable('cvar')
455	cvar.version = 1
456	cvar.variations = []
457
458	deltas, supports = masterModel.getDeltasAndSupports(all_cvs)
459	for i,(delta,support) in enumerate(zip(deltas[1:], supports[1:])):
460		delta = [otRound(d) for d in delta]
461		if all(abs(v) <= tolerance for v in delta):
462			continue
463		var = TupleVariation(support, delta)
464		cvar.variations.append(var)
465
466def _add_HVAR(font, masterModel, master_ttfs, axisTags):
467
468	log.info("Generating HVAR")
469
470	glyphOrder = font.getGlyphOrder()
471
472	hAdvanceDeltasAndSupports = {}
473	metricses = [m["hmtx"].metrics for m in master_ttfs]
474	for glyph in glyphOrder:
475		hAdvances = [metrics[glyph][0] if glyph in metrics else None for metrics in metricses]
476		hAdvanceDeltasAndSupports[glyph] = masterModel.getDeltasAndSupports(hAdvances)
477
478	singleModel = models.allEqual(id(v[1]) for v in hAdvanceDeltasAndSupports.values())
479
480	directStore = None
481	if singleModel:
482		# Build direct mapping
483
484		supports = next(iter(hAdvanceDeltasAndSupports.values()))[1][1:]
485		varTupleList = builder.buildVarRegionList(supports, axisTags)
486		varTupleIndexes = list(range(len(supports)))
487		varData = builder.buildVarData(varTupleIndexes, [], optimize=False)
488		for glyphName in glyphOrder:
489			varData.addItem(hAdvanceDeltasAndSupports[glyphName][0])
490		varData.optimize()
491		directStore = builder.buildVarStore(varTupleList, [varData])
492
493	# Build optimized indirect mapping
494	storeBuilder = varStore.OnlineVarStoreBuilder(axisTags)
495	mapping = {}
496	for glyphName in glyphOrder:
497		deltas,supports = hAdvanceDeltasAndSupports[glyphName]
498		storeBuilder.setSupports(supports)
499		mapping[glyphName] = storeBuilder.storeDeltas(deltas)
500	indirectStore = storeBuilder.finish()
501	mapping2 = indirectStore.optimize()
502	mapping = [mapping2[mapping[g]] for g in glyphOrder]
503	advanceMapping = builder.buildVarIdxMap(mapping, glyphOrder)
504
505	use_direct = False
506	if directStore:
507		# Compile both, see which is more compact
508
509		writer = OTTableWriter()
510		directStore.compile(writer, font)
511		directSize = len(writer.getAllData())
512
513		writer = OTTableWriter()
514		indirectStore.compile(writer, font)
515		advanceMapping.compile(writer, font)
516		indirectSize = len(writer.getAllData())
517
518		use_direct = directSize < indirectSize
519
520	# Done; put it all together.
521	assert "HVAR" not in font
522	HVAR = font["HVAR"] = newTable('HVAR')
523	hvar = HVAR.table = ot.HVAR()
524	hvar.Version = 0x00010000
525	hvar.LsbMap = hvar.RsbMap = None
526	if use_direct:
527		hvar.VarStore = directStore
528		hvar.AdvWidthMap = None
529	else:
530		hvar.VarStore = indirectStore
531		hvar.AdvWidthMap = advanceMapping
532
533def _add_MVAR(font, masterModel, master_ttfs, axisTags):
534
535	log.info("Generating MVAR")
536
537	store_builder = varStore.OnlineVarStoreBuilder(axisTags)
538
539	records = []
540	lastTableTag = None
541	fontTable = None
542	tables = None
543	# HACK: we need to special-case post.underlineThickness and .underlinePosition
544	# and unilaterally/arbitrarily define a sentinel value to distinguish the case
545	# when a post table is present in a given master simply because that's where
546	# the glyph names in TrueType must be stored, but the underline values are not
547	# meant to be used for building MVAR's deltas. The value of -0x8000 (-36768)
548	# the minimum FWord (int16) value, was chosen for its unlikelyhood to appear
549	# in real-world underline position/thickness values.
550	specialTags = {"unds": -0x8000, "undo": -0x8000}
551
552	for tag, (tableTag, itemName) in sorted(MVAR_ENTRIES.items(), key=lambda kv: kv[1]):
553		# For each tag, fetch the associated table from all fonts (or not when we are
554		# still looking at a tag from the same tables) and set up the variation model
555		# for them.
556		if tableTag != lastTableTag:
557			tables = fontTable = None
558			if tableTag in font:
559				fontTable = font[tableTag]
560				tables = []
561				for master in master_ttfs:
562					if tableTag not in master or (
563						tag in specialTags
564						and getattr(master[tableTag], itemName) == specialTags[tag]
565					):
566						tables.append(None)
567					else:
568						tables.append(master[tableTag])
569				model, tables = masterModel.getSubModel(tables)
570				store_builder.setModel(model)
571			lastTableTag = tableTag
572
573		if tables is None:  # Tag not applicable to the master font.
574			continue
575
576		# TODO support gasp entries
577
578		master_values = [getattr(table, itemName) for table in tables]
579		if models.allEqual(master_values):
580			base, varIdx = master_values[0], None
581		else:
582			base, varIdx = store_builder.storeMasters(master_values)
583		setattr(fontTable, itemName, base)
584
585		if varIdx is None:
586			continue
587		log.info('	%s: %s.%s	%s', tag, tableTag, itemName, master_values)
588		rec = ot.MetricsValueRecord()
589		rec.ValueTag = tag
590		rec.VarIdx = varIdx
591		records.append(rec)
592
593	assert "MVAR" not in font
594	if records:
595		store = store_builder.finish()
596		# Optimize
597		mapping = store.optimize()
598		for rec in records:
599			rec.VarIdx = mapping[rec.VarIdx]
600
601		MVAR = font["MVAR"] = newTable('MVAR')
602		mvar = MVAR.table = ot.MVAR()
603		mvar.Version = 0x00010000
604		mvar.Reserved = 0
605		mvar.VarStore = store
606		# XXX these should not be hard-coded but computed automatically
607		mvar.ValueRecordSize = 8
608		mvar.ValueRecordCount = len(records)
609		mvar.ValueRecord = sorted(records, key=lambda r: r.ValueTag)
610
611
612def _merge_OTL(font, model, master_fonts, axisTags):
613
614	log.info("Merging OpenType Layout tables")
615	merger = VariationMerger(model, axisTags, font)
616
617	merger.mergeTables(font, master_fonts, ['GSUB', 'GDEF', 'GPOS'])
618	store = merger.store_builder.finish()
619	if not store.VarData:
620		return
621	try:
622		GDEF = font['GDEF'].table
623		assert GDEF.Version <= 0x00010002
624	except KeyError:
625		font['GDEF']= newTable('GDEF')
626		GDEFTable = font["GDEF"] = newTable('GDEF')
627		GDEF = GDEFTable.table = ot.GDEF()
628	GDEF.Version = 0x00010003
629	GDEF.VarStore = store
630
631	# Optimize
632	varidx_map = store.optimize()
633	GDEF.remap_device_varidxes(varidx_map)
634	if 'GPOS' in font:
635		font['GPOS'].table.remap_device_varidxes(varidx_map)
636
637
638def _add_GSUB_feature_variations(font, axes, internal_axis_supports, rules):
639
640	def normalize(name, value):
641		return models.normalizeLocation(
642			{name: value}, internal_axis_supports
643		)[name]
644
645	log.info("Generating GSUB FeatureVariations")
646
647	axis_tags = {name: axis.tag for name, axis in axes.items()}
648
649	conditional_subs = []
650	for rule in rules:
651
652		region = []
653		for conditions in rule.conditionSets:
654			space = {}
655			for condition in conditions:
656				axis_name = condition["name"]
657				if condition["minimum"] is not None:
658					minimum = normalize(axis_name, condition["minimum"])
659				else:
660					minimum = -1.0
661				if condition["maximum"] is not None:
662					maximum = normalize(axis_name, condition["maximum"])
663				else:
664					maximum = 1.0
665				tag = axis_tags[axis_name]
666				space[tag] = (minimum, maximum)
667			region.append(space)
668
669		subs = {k: v for k, v in rule.subs}
670
671		conditional_subs.append((region, subs))
672
673	addFeatureVariations(font, conditional_subs)
674
675
676_DesignSpaceData = namedtuple(
677	"_DesignSpaceData",
678	[
679		"axes",
680		"internal_axis_supports",
681		"base_idx",
682		"normalized_master_locs",
683		"masters",
684		"instances",
685		"rules",
686	],
687)
688
689
690def _add_CFF2(varFont, model, master_fonts):
691	from .cff import (convertCFFtoCFF2, addCFFVarStore, merge_region_fonts)
692	glyphOrder = varFont.getGlyphOrder()
693	convertCFFtoCFF2(varFont)
694	ordered_fonts_list = model.reorderMasters(master_fonts, model.reverseMapping)
695	# re-ordering the master list simplifies building the CFF2 data item lists.
696	addCFFVarStore(varFont, model)  # Add VarStore to the CFF2 font.
697	merge_region_fonts(varFont, model, ordered_fonts_list, glyphOrder)
698
699
700def load_designspace(designspace):
701	# TODO: remove this and always assume 'designspace' is a DesignSpaceDocument,
702	# never a file path, as that's already handled by caller
703	if hasattr(designspace, "sources"):  # Assume a DesignspaceDocument
704		ds = designspace
705	else:  # Assume a file path
706		ds = DesignSpaceDocument.fromfile(designspace)
707
708	masters = ds.sources
709	if not masters:
710		raise VarLibError("no sources found in .designspace")
711	instances = ds.instances
712
713	standard_axis_map = OrderedDict([
714		('weight',  ('wght', {'en': u'Weight'})),
715		('width',   ('wdth', {'en': u'Width'})),
716		('slant',   ('slnt', {'en': u'Slant'})),
717		('optical', ('opsz', {'en': u'Optical Size'})),
718		('italic',  ('ital', {'en': u'Italic'})),
719		])
720
721	# Setup axes
722	axes = OrderedDict()
723	for axis in ds.axes:
724		axis_name = axis.name
725		if not axis_name:
726			assert axis.tag is not None
727			axis_name = axis.name = axis.tag
728
729		if axis_name in standard_axis_map:
730			if axis.tag is None:
731				axis.tag = standard_axis_map[axis_name][0]
732			if not axis.labelNames:
733				axis.labelNames.update(standard_axis_map[axis_name][1])
734		else:
735			assert axis.tag is not None
736			if not axis.labelNames:
737				axis.labelNames["en"] = tounicode(axis_name)
738
739		axes[axis_name] = axis
740	log.info("Axes:\n%s", pformat([axis.asdict() for axis in axes.values()]))
741
742	# Check all master and instance locations are valid and fill in defaults
743	for obj in masters+instances:
744		obj_name = obj.name or obj.styleName or ''
745		loc = obj.location
746		for axis_name in loc.keys():
747			assert axis_name in axes, "Location axis '%s' unknown for '%s'." % (axis_name, obj_name)
748		for axis_name,axis in axes.items():
749			if axis_name not in loc:
750				loc[axis_name] = axis.default
751			else:
752				v = axis.map_backward(loc[axis_name])
753				assert axis.minimum <= v <= axis.maximum, "Location for axis '%s' (mapped to %s) out of range for '%s' [%s..%s]" % (axis_name, v, obj_name, axis.minimum, axis.maximum)
754
755	# Normalize master locations
756
757	internal_master_locs = [o.location for o in masters]
758	log.info("Internal master locations:\n%s", pformat(internal_master_locs))
759
760	# TODO This mapping should ideally be moved closer to logic in _add_fvar/avar
761	internal_axis_supports = {}
762	for axis in axes.values():
763		triple = (axis.minimum, axis.default, axis.maximum)
764		internal_axis_supports[axis.name] = [axis.map_forward(v) for v in triple]
765	log.info("Internal axis supports:\n%s", pformat(internal_axis_supports))
766
767	normalized_master_locs = [models.normalizeLocation(m, internal_axis_supports) for m in internal_master_locs]
768	log.info("Normalized master locations:\n%s", pformat(normalized_master_locs))
769
770	# Find base master
771	base_idx = None
772	for i,m in enumerate(normalized_master_locs):
773		if all(v == 0 for v in m.values()):
774			assert base_idx is None
775			base_idx = i
776	assert base_idx is not None, "Base master not found; no master at default location?"
777	log.info("Index of base master: %s", base_idx)
778
779	return _DesignSpaceData(
780		axes,
781		internal_axis_supports,
782		base_idx,
783		normalized_master_locs,
784		masters,
785		instances,
786		ds.rules,
787	)
788
789
790def build(designspace, master_finder=lambda s:s, exclude=[], optimize=True):
791	"""
792	Build variation font from a designspace file.
793
794	If master_finder is set, it should be a callable that takes master
795	filename as found in designspace file and map it to master font
796	binary as to be opened (eg. .ttf or .otf).
797	"""
798	if hasattr(designspace, "sources"):  # Assume a DesignspaceDocument
799		pass
800	else:  # Assume a file path
801		designspace = DesignSpaceDocument.fromfile(designspace)
802
803	ds = load_designspace(designspace)
804	log.info("Building variable font")
805
806	log.info("Loading master fonts")
807	master_fonts = load_masters(designspace, master_finder)
808
809	# TODO: 'master_ttfs' is unused except for return value, remove later
810	master_ttfs = []
811	for master in master_fonts:
812		try:
813			master_ttfs.append(master.reader.file.name)
814		except AttributeError:
815			master_ttfs.append(None)  # in-memory fonts have no path
816
817	# Copy the base master to work from it
818	vf = deepcopy(master_fonts[ds.base_idx])
819
820	# TODO append masters as named-instances as well; needs .designspace change.
821	fvar = _add_fvar(vf, ds.axes, ds.instances)
822	if 'STAT' not in exclude:
823		_add_stat(vf, ds.axes)
824	if 'avar' not in exclude:
825		_add_avar(vf, ds.axes)
826
827	# Map from axis names to axis tags...
828	normalized_master_locs = [
829		{ds.axes[k].tag: v for k,v in loc.items()} for loc in ds.normalized_master_locs
830	]
831	# From here on, we use fvar axes only
832	axisTags = [axis.axisTag for axis in fvar.axes]
833
834	# Assume single-model for now.
835	model = models.VariationModel(normalized_master_locs, axisOrder=axisTags)
836	assert 0 == model.mapping[ds.base_idx]
837
838	log.info("Building variations tables")
839	if 'MVAR' not in exclude:
840		_add_MVAR(vf, model, master_fonts, axisTags)
841	if 'HVAR' not in exclude:
842		_add_HVAR(vf, model, master_fonts, axisTags)
843	if 'GDEF' not in exclude or 'GPOS' not in exclude:
844		_merge_OTL(vf, model, master_fonts, axisTags)
845	if 'gvar' not in exclude and 'glyf' in vf:
846		_add_gvar(vf, model, master_fonts, optimize=optimize)
847	if 'cvar' not in exclude and 'glyf' in vf:
848		_merge_TTHinting(vf, model, master_fonts)
849	if 'GSUB' not in exclude and ds.rules:
850		_add_GSUB_feature_variations(vf, ds.axes, ds.internal_axis_supports, ds.rules)
851	if 'CFF2' not in exclude and 'CFF ' in vf:
852		_add_CFF2(vf, model, master_fonts)
853
854	for tag in exclude:
855		if tag in vf:
856			del vf[tag]
857
858	# TODO: Only return vf for 4.0+, the rest is unused.
859	return vf, model, master_ttfs
860
861
862def _open_font(path, master_finder):
863	# load TTFont masters from given 'path': this can be either a .TTX or an
864	# OpenType binary font; or if neither of these, try use the 'master_finder'
865	# callable to resolve the path to a valid .TTX or OpenType font binary.
866	from fontTools.ttx import guessFileType
867
868	master_path = os.path.normpath(path)
869	tp = guessFileType(master_path)
870	if tp is None:
871		# not an OpenType binary/ttx, fall back to the master finder.
872		master_path = master_finder(master_path)
873		tp = guessFileType(master_path)
874	if tp in ("TTX", "OTX"):
875		font = TTFont()
876		font.importXML(master_path)
877	elif tp in ("TTF", "OTF", "WOFF", "WOFF2"):
878		font = TTFont(master_path)
879	else:
880		raise VarLibError("Invalid master path: %r" % master_path)
881	return font
882
883
884def load_masters(designspace, master_finder=lambda s: s):
885	"""Ensure that all SourceDescriptor.font attributes have an appropriate TTFont
886	object loaded, or else open TTFont objects from the SourceDescriptor.path
887	attributes.
888
889	The paths can point to either an OpenType font, a TTX file, or a UFO. In the
890	latter case, use the provided master_finder callable to map from UFO paths to
891	the respective master font binaries (e.g. .ttf, .otf or .ttx).
892
893	Return list of master TTFont objects in the same order they are listed in the
894	DesignSpaceDocument.
895	"""
896	master_fonts = []
897
898	for master in designspace.sources:
899		# 1. If the caller already supplies a TTFont for a source, just take it.
900		if master.font:
901			font = master.font
902			master_fonts.append(font)
903		else:
904			# If a SourceDescriptor has a layer name, demand that the compiled TTFont
905			# be supplied by the caller. This spares us from modifying MasterFinder.
906			if master.layerName:
907				raise AttributeError(
908					"Designspace source '%s' specified a layer name but lacks the "
909					"required TTFont object in the 'font' attribute."
910					% (master.name or "<Unknown>")
911			)
912			else:
913				if master.path is None:
914					raise AttributeError(
915						"Designspace source '%s' has neither 'font' nor 'path' "
916						"attributes" % (master.name or "<Unknown>")
917					)
918				# 2. A SourceDescriptor's path might point an OpenType binary, a
919				# TTX file, or another source file (e.g. UFO), in which case we
920				# resolve the path using 'master_finder' function
921				master.font = font = _open_font(master.path, master_finder)
922				master_fonts.append(font)
923
924	return master_fonts
925
926
927class MasterFinder(object):
928
929	def __init__(self, template):
930		self.template = template
931
932	def __call__(self, src_path):
933		fullname = os.path.abspath(src_path)
934		dirname, basename = os.path.split(fullname)
935		stem, ext = os.path.splitext(basename)
936		path = self.template.format(
937			fullname=fullname,
938			dirname=dirname,
939			basename=basename,
940			stem=stem,
941			ext=ext,
942		)
943		return os.path.normpath(path)
944
945
946def main(args=None):
947	from argparse import ArgumentParser
948	from fontTools import configLogger
949
950	parser = ArgumentParser(prog='varLib')
951	parser.add_argument('designspace')
952	parser.add_argument(
953		'-o',
954		metavar='OUTPUTFILE',
955		dest='outfile',
956		default=None,
957		help='output file'
958	)
959	parser.add_argument(
960		'-x',
961		metavar='TAG',
962		dest='exclude',
963		action='append',
964		default=[],
965		help='exclude table'
966	)
967	parser.add_argument(
968		'--disable-iup',
969		dest='optimize',
970		action='store_false',
971		help='do not perform IUP optimization'
972	)
973	parser.add_argument(
974		'--master-finder',
975		default='master_ttf_interpolatable/{stem}.ttf',
976		help=(
977			'templated string used for finding binary font '
978			'files given the source file names defined in the '
979			'designspace document. The following special strings '
980			'are defined: {fullname} is the absolute source file '
981			'name; {basename} is the file name without its '
982			'directory; {stem} is the basename without the file '
983			'extension; {ext} is the source file extension; '
984			'{dirname} is the directory of the absolute file '
985			'name. The default value is "%(default)s".'
986		)
987	)
988	options = parser.parse_args(args)
989
990	# TODO: allow user to configure logging via command-line options
991	configLogger(level="INFO")
992
993	designspace_filename = options.designspace
994	finder = MasterFinder(options.master_finder)
995	outfile = options.outfile
996	if outfile is None:
997		outfile = os.path.splitext(designspace_filename)[0] + '-VF.ttf'
998
999	vf, _, _ = build(
1000		designspace_filename,
1001		finder,
1002		exclude=options.exclude,
1003		optimize=options.optimize
1004	)
1005
1006	log.info("Saving variation font %s", outfile)
1007	vf.save(outfile)
1008
1009
1010if __name__ == "__main__":
1011	import sys
1012	if len(sys.argv) > 1:
1013		sys.exit(main())
1014	import doctest
1015	sys.exit(doctest.testmod().failed)
1016