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