• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1"""
2Instantiate a variation font.  Run, eg:
3
4$ fonttools varLib.mutator ./NotoSansArabic-VF.ttf wght=140 wdth=85
5"""
6from fontTools.misc.fixedTools import floatToFixedToFloat, floatToFixed
7from fontTools.misc.roundTools import otRound
8from fontTools.pens.boundsPen import BoundsPen
9from fontTools.ttLib import TTFont, newTable
10from fontTools.ttLib.tables import ttProgram
11from fontTools.ttLib.tables._g_l_y_f import GlyphCoordinates, flagOverlapSimple, OVERLAP_COMPOUND
12from fontTools.varLib.models import (
13	supportScalar,
14	normalizeLocation,
15	piecewiseLinearMap,
16)
17from fontTools.varLib.merger import MutatorMerger
18from fontTools.varLib.varStore import VarStoreInstancer
19from fontTools.varLib.mvar import MVAR_ENTRIES
20from fontTools.varLib.iup import iup_delta
21import fontTools.subset.cff
22import os.path
23import logging
24from io import BytesIO
25
26
27log = logging.getLogger("fontTools.varlib.mutator")
28
29# map 'wdth' axis (1..200) to OS/2.usWidthClass (1..9), rounding to closest
30OS2_WIDTH_CLASS_VALUES = {}
31percents = [50.0, 62.5, 75.0, 87.5, 100.0, 112.5, 125.0, 150.0, 200.0]
32for i, (prev, curr) in enumerate(zip(percents[:-1], percents[1:]), start=1):
33	half = (prev + curr) / 2
34	OS2_WIDTH_CLASS_VALUES[half] = i
35
36
37def interpolate_cff2_PrivateDict(topDict, interpolateFromDeltas):
38	pd_blend_lists = ("BlueValues", "OtherBlues", "FamilyBlues",
39						"FamilyOtherBlues", "StemSnapH",
40						"StemSnapV")
41	pd_blend_values = ("BlueScale", "BlueShift",
42						"BlueFuzz", "StdHW", "StdVW")
43	for fontDict in topDict.FDArray:
44		pd = fontDict.Private
45		vsindex = pd.vsindex if (hasattr(pd, 'vsindex')) else 0
46		for key, value in pd.rawDict.items():
47			if (key in pd_blend_values) and isinstance(value, list):
48					delta = interpolateFromDeltas(vsindex, value[1:])
49					pd.rawDict[key] = otRound(value[0] + delta)
50			elif (key in pd_blend_lists) and isinstance(value[0], list):
51				"""If any argument in a BlueValues list is a blend list,
52				then they all are. The first value of each list is an
53				absolute value. The delta tuples are calculated from
54				relative master values, hence we need to append all the
55				deltas to date to each successive absolute value."""
56				delta = 0
57				for i, val_list in enumerate(value):
58					delta += otRound(interpolateFromDeltas(vsindex,
59										val_list[1:]))
60					value[i] = val_list[0] + delta
61
62
63def interpolate_cff2_charstrings(topDict, interpolateFromDeltas, glyphOrder):
64	charstrings = topDict.CharStrings
65	for gname in glyphOrder:
66		# Interpolate charstring
67		# e.g replace blend op args with regular args,
68		# and use and discard vsindex op.
69		charstring = charstrings[gname]
70		new_program = []
71		vsindex = 0
72		last_i = 0
73		for i, token in enumerate(charstring.program):
74			if token == 'vsindex':
75				vsindex = charstring.program[i - 1]
76				if last_i != 0:
77					new_program.extend(charstring.program[last_i:i - 1])
78				last_i = i + 1
79			elif token == 'blend':
80				num_regions = charstring.getNumRegions(vsindex)
81				numMasters = 1 + num_regions
82				num_args = charstring.program[i - 1]
83				# The program list starting at program[i] is now:
84				# ..args for following operations
85				# num_args values  from the default font
86				# num_args tuples, each with numMasters-1 delta values
87				# num_blend_args
88				# 'blend'
89				argi = i - (num_args * numMasters + 1)
90				end_args = tuplei = argi + num_args
91				while argi < end_args:
92					next_ti = tuplei + num_regions
93					deltas = charstring.program[tuplei:next_ti]
94					delta = interpolateFromDeltas(vsindex, deltas)
95					charstring.program[argi] += otRound(delta)
96					tuplei = next_ti
97					argi += 1
98				new_program.extend(charstring.program[last_i:end_args])
99				last_i = i + 1
100		if last_i != 0:
101			new_program.extend(charstring.program[last_i:])
102			charstring.program = new_program
103
104
105def interpolate_cff2_metrics(varfont, topDict, glyphOrder, loc):
106	"""Unlike TrueType glyphs, neither advance width nor bounding box
107	info is stored in a CFF2 charstring. The width data exists only in
108	the hmtx and HVAR tables. Since LSB data cannot be interpolated
109	reliably from the master LSB values in the hmtx table, we traverse
110	the charstring to determine the actual bound box. """
111
112	charstrings = topDict.CharStrings
113	boundsPen = BoundsPen(glyphOrder)
114	hmtx = varfont['hmtx']
115	hvar_table = None
116	if 'HVAR' in varfont:
117		hvar_table = varfont['HVAR'].table
118		fvar = varfont['fvar']
119		varStoreInstancer = VarStoreInstancer(hvar_table.VarStore, fvar.axes, loc)
120
121	for gid, gname in enumerate(glyphOrder):
122		entry = list(hmtx[gname])
123		# get width delta.
124		if hvar_table:
125			if hvar_table.AdvWidthMap:
126				width_idx = hvar_table.AdvWidthMap.mapping[gname]
127			else:
128				width_idx = gid
129			width_delta = otRound(varStoreInstancer[width_idx])
130		else:
131			width_delta = 0
132
133		# get LSB.
134		boundsPen.init()
135		charstring = charstrings[gname]
136		charstring.draw(boundsPen)
137		if boundsPen.bounds is None:
138			# Happens with non-marking glyphs
139			lsb_delta = 0
140		else:
141			lsb = otRound(boundsPen.bounds[0])
142			lsb_delta = entry[1] - lsb
143
144		if lsb_delta or width_delta:
145			if width_delta:
146				entry[0] += width_delta
147			if lsb_delta:
148				entry[1] = lsb
149			hmtx[gname] = tuple(entry)
150
151
152def instantiateVariableFont(varfont, location, inplace=False, overlap=True):
153	""" Generate a static instance from a variable TTFont and a dictionary
154	defining the desired location along the variable font's axes.
155	The location values must be specified as user-space coordinates, e.g.:
156
157		{'wght': 400, 'wdth': 100}
158
159	By default, a new TTFont object is returned. If ``inplace`` is True, the
160	input varfont is modified and reduced to a static font.
161
162	When the overlap parameter is defined as True,
163	OVERLAP_SIMPLE and OVERLAP_COMPOUND bits are set to 1.  See
164	https://docs.microsoft.com/en-us/typography/opentype/spec/glyf
165	"""
166	if not inplace:
167		# make a copy to leave input varfont unmodified
168		stream = BytesIO()
169		varfont.save(stream)
170		stream.seek(0)
171		varfont = TTFont(stream)
172
173	fvar = varfont['fvar']
174	axes = {a.axisTag:(a.minValue,a.defaultValue,a.maxValue) for a in fvar.axes}
175	loc = normalizeLocation(location, axes)
176	if 'avar' in varfont:
177		maps = varfont['avar'].segments
178		loc = {k: piecewiseLinearMap(v, maps[k]) for k,v in loc.items()}
179	# Quantize to F2Dot14, to avoid surprise interpolations.
180	loc = {k:floatToFixedToFloat(v, 14) for k,v in loc.items()}
181	# Location is normalized now
182	log.info("Normalized location: %s", loc)
183
184	if 'gvar' in varfont:
185		log.info("Mutating glyf/gvar tables")
186		gvar = varfont['gvar']
187		glyf = varfont['glyf']
188		hMetrics = varfont['hmtx'].metrics
189		vMetrics = getattr(varfont.get('vmtx'), 'metrics', None)
190		# get list of glyph names in gvar sorted by component depth
191		glyphnames = sorted(
192			gvar.variations.keys(),
193			key=lambda name: (
194				glyf[name].getCompositeMaxpValues(glyf).maxComponentDepth
195				if glyf[name].isComposite() else 0,
196				name))
197		for glyphname in glyphnames:
198			variations = gvar.variations[glyphname]
199			coordinates, _ = glyf._getCoordinatesAndControls(glyphname, hMetrics, vMetrics)
200			origCoords, endPts = None, None
201			for var in variations:
202				scalar = supportScalar(loc, var.axes)
203				if not scalar: continue
204				delta = var.coordinates
205				if None in delta:
206					if origCoords is None:
207						origCoords, g = glyf._getCoordinatesAndControls(glyphname, hMetrics, vMetrics)
208					delta = iup_delta(delta, origCoords, g.endPts)
209				coordinates += GlyphCoordinates(delta) * scalar
210			glyf._setCoordinates(glyphname, coordinates, hMetrics, vMetrics)
211	else:
212		glyf = None
213
214	if 'cvar' in varfont:
215		log.info("Mutating cvt/cvar tables")
216		cvar = varfont['cvar']
217		cvt = varfont['cvt ']
218		deltas = {}
219		for var in cvar.variations:
220			scalar = supportScalar(loc, var.axes)
221			if not scalar: continue
222			for i, c in enumerate(var.coordinates):
223				if c is not None:
224					deltas[i] = deltas.get(i, 0) + scalar * c
225		for i, delta in deltas.items():
226			cvt[i] += otRound(delta)
227
228	if 'CFF2' in varfont:
229		log.info("Mutating CFF2 table")
230		glyphOrder = varfont.getGlyphOrder()
231		CFF2 = varfont['CFF2']
232		topDict = CFF2.cff.topDictIndex[0]
233		vsInstancer = VarStoreInstancer(topDict.VarStore.otVarStore, fvar.axes, loc)
234		interpolateFromDeltas = vsInstancer.interpolateFromDeltas
235		interpolate_cff2_PrivateDict(topDict, interpolateFromDeltas)
236		CFF2.desubroutinize()
237		interpolate_cff2_charstrings(topDict, interpolateFromDeltas, glyphOrder)
238		interpolate_cff2_metrics(varfont, topDict, glyphOrder, loc)
239		del topDict.rawDict['VarStore']
240		del topDict.VarStore
241
242	if 'MVAR' in varfont:
243		log.info("Mutating MVAR table")
244		mvar = varfont['MVAR'].table
245		varStoreInstancer = VarStoreInstancer(mvar.VarStore, fvar.axes, loc)
246		records = mvar.ValueRecord
247		for rec in records:
248			mvarTag = rec.ValueTag
249			if mvarTag not in MVAR_ENTRIES:
250				continue
251			tableTag, itemName = MVAR_ENTRIES[mvarTag]
252			delta = otRound(varStoreInstancer[rec.VarIdx])
253			if not delta:
254				continue
255			setattr(varfont[tableTag], itemName,
256				getattr(varfont[tableTag], itemName) + delta)
257
258	log.info("Mutating FeatureVariations")
259	for tableTag in 'GSUB','GPOS':
260		if not tableTag in varfont:
261			continue
262		table = varfont[tableTag].table
263		if not getattr(table, 'FeatureVariations', None):
264			continue
265		variations = table.FeatureVariations
266		for record in variations.FeatureVariationRecord:
267			applies = True
268			for condition in record.ConditionSet.ConditionTable:
269				if condition.Format == 1:
270					axisIdx = condition.AxisIndex
271					axisTag = fvar.axes[axisIdx].axisTag
272					Min = condition.FilterRangeMinValue
273					Max = condition.FilterRangeMaxValue
274					v = loc[axisTag]
275					if not (Min <= v <= Max):
276						applies = False
277				else:
278					applies = False
279				if not applies:
280					break
281
282			if applies:
283				assert record.FeatureTableSubstitution.Version == 0x00010000
284				for rec in record.FeatureTableSubstitution.SubstitutionRecord:
285					table.FeatureList.FeatureRecord[rec.FeatureIndex].Feature = rec.Feature
286				break
287		del table.FeatureVariations
288
289	if 'GDEF' in varfont and varfont['GDEF'].table.Version >= 0x00010003:
290		log.info("Mutating GDEF/GPOS/GSUB tables")
291		gdef = varfont['GDEF'].table
292		instancer = VarStoreInstancer(gdef.VarStore, fvar.axes, loc)
293
294		merger = MutatorMerger(varfont, instancer)
295		merger.mergeTables(varfont, [varfont], ['GDEF', 'GPOS'])
296
297		# Downgrade GDEF.
298		del gdef.VarStore
299		gdef.Version = 0x00010002
300		if gdef.MarkGlyphSetsDef is None:
301			del gdef.MarkGlyphSetsDef
302			gdef.Version = 0x00010000
303
304		if not (gdef.LigCaretList or
305			gdef.MarkAttachClassDef or
306			gdef.GlyphClassDef or
307			gdef.AttachList or
308			(gdef.Version >= 0x00010002 and gdef.MarkGlyphSetsDef)):
309			del varfont['GDEF']
310
311	addidef = False
312	if glyf:
313		for glyph in glyf.glyphs.values():
314			if hasattr(glyph, "program"):
315				instructions = glyph.program.getAssembly()
316				# If GETVARIATION opcode is used in bytecode of any glyph add IDEF
317				addidef = any(op.startswith("GETVARIATION") for op in instructions)
318				if addidef:
319					break
320		if overlap:
321			for glyph_name in glyf.keys():
322				glyph = glyf[glyph_name]
323				# Set OVERLAP_COMPOUND bit for compound glyphs
324				if glyph.isComposite():
325					glyph.components[0].flags |= OVERLAP_COMPOUND
326				# Set OVERLAP_SIMPLE bit for simple glyphs
327				elif glyph.numberOfContours > 0:
328					glyph.flags[0] |= flagOverlapSimple
329	if addidef:
330		log.info("Adding IDEF to fpgm table for GETVARIATION opcode")
331		asm = []
332		if 'fpgm' in varfont:
333			fpgm = varfont['fpgm']
334			asm = fpgm.program.getAssembly()
335		else:
336			fpgm = newTable('fpgm')
337			fpgm.program = ttProgram.Program()
338			varfont['fpgm'] = fpgm
339		asm.append("PUSHB[000] 145")
340		asm.append("IDEF[ ]")
341		args = [str(len(loc))]
342		for a in fvar.axes:
343			args.append(str(floatToFixed(loc[a.axisTag], 14)))
344		asm.append("NPUSHW[ ] " + ' '.join(args))
345		asm.append("ENDF[ ]")
346		fpgm.program.fromAssembly(asm)
347
348		# Change maxp attributes as IDEF is added
349		if 'maxp' in varfont:
350			maxp = varfont['maxp']
351			setattr(maxp, "maxInstructionDefs", 1 + getattr(maxp, "maxInstructionDefs", 0))
352			setattr(maxp, "maxStackElements", max(len(loc), getattr(maxp, "maxStackElements", 0)))
353
354	if 'name' in varfont:
355		log.info("Pruning name table")
356		exclude = {a.axisNameID for a in fvar.axes}
357		for i in fvar.instances:
358			exclude.add(i.subfamilyNameID)
359			exclude.add(i.postscriptNameID)
360		if 'ltag' in varfont:
361			# Drop the whole 'ltag' table if all its language tags are referenced by
362			# name records to be pruned.
363			# TODO: prune unused ltag tags and re-enumerate langIDs accordingly
364			excludedUnicodeLangIDs = [
365				n.langID for n in varfont['name'].names
366				if n.nameID in exclude and n.platformID == 0 and n.langID != 0xFFFF
367			]
368			if set(excludedUnicodeLangIDs) == set(range(len((varfont['ltag'].tags)))):
369				del varfont['ltag']
370		varfont['name'].names[:] = [
371			n for n in varfont['name'].names
372			if n.nameID not in exclude
373		]
374
375	if "wght" in location and "OS/2" in varfont:
376		varfont["OS/2"].usWeightClass = otRound(
377			max(1, min(location["wght"], 1000))
378		)
379	if "wdth" in location:
380		wdth = location["wdth"]
381		for percent, widthClass in sorted(OS2_WIDTH_CLASS_VALUES.items()):
382			if wdth < percent:
383				varfont["OS/2"].usWidthClass = widthClass
384				break
385		else:
386			varfont["OS/2"].usWidthClass = 9
387	if "slnt" in location and "post" in varfont:
388		varfont["post"].italicAngle = max(-90, min(location["slnt"], 90))
389
390	log.info("Removing variable tables")
391	for tag in ('avar','cvar','fvar','gvar','HVAR','MVAR','VVAR','STAT'):
392		if tag in varfont:
393			del varfont[tag]
394
395	return varfont
396
397
398def main(args=None):
399	"""Instantiate a variation font"""
400	from fontTools import configLogger
401	import argparse
402
403	parser = argparse.ArgumentParser(
404		"fonttools varLib.mutator", description="Instantiate a variable font")
405	parser.add_argument(
406		"input", metavar="INPUT.ttf", help="Input variable TTF file.")
407	parser.add_argument(
408		"locargs", metavar="AXIS=LOC", nargs="*",
409		help="List of space separated locations. A location consist in "
410		"the name of a variation axis, followed by '=' and a number. E.g.: "
411		" wght=700 wdth=80. The default is the location of the base master.")
412	parser.add_argument(
413		"-o", "--output", metavar="OUTPUT.ttf", default=None,
414		help="Output instance TTF file (default: INPUT-instance.ttf).")
415	logging_group = parser.add_mutually_exclusive_group(required=False)
416	logging_group.add_argument(
417		"-v", "--verbose", action="store_true", help="Run more verbosely.")
418	logging_group.add_argument(
419		"-q", "--quiet", action="store_true", help="Turn verbosity off.")
420	parser.add_argument(
421		"--no-overlap",
422		dest="overlap",
423		action="store_false",
424		help="Don't set OVERLAP_SIMPLE/OVERLAP_COMPOUND glyf flags."
425	)
426	options = parser.parse_args(args)
427
428	varfilename = options.input
429	outfile = (
430		os.path.splitext(varfilename)[0] + '-instance.ttf'
431		if not options.output else options.output)
432	configLogger(level=(
433		"DEBUG" if options.verbose else
434		"ERROR" if options.quiet else
435		"INFO"))
436
437	loc = {}
438	for arg in options.locargs:
439		try:
440			tag, val = arg.split('=')
441			assert len(tag) <= 4
442			loc[tag.ljust(4)] = float(val)
443		except (ValueError, AssertionError):
444			parser.error("invalid location argument format: %r" % arg)
445	log.info("Location: %s", loc)
446
447	log.info("Loading variable font")
448	varfont = TTFont(varfilename)
449
450	instantiateVariableFont(varfont, loc, inplace=True, overlap=options.overlap)
451
452	log.info("Saving instance font %s", outfile)
453	varfont.save(outfile)
454
455
456if __name__ == "__main__":
457	import sys
458	if len(sys.argv) > 1:
459		sys.exit(main())
460	import doctest
461	sys.exit(doctest.testmod().failed)
462