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