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