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