Home | History | Annotate | Download | only in varLib
      1 """
      2 Instantiate a variation font.  Run, eg:
      3 
      4 $ fonttools varLib.mutator ./NotoSansArabic-VF.ttf wght=140 wdth=85
      5 """
      6 from __future__ import print_function, division, absolute_import
      7 from fontTools.misc.py23 import *
      8 from fontTools.misc.fixedTools import floatToFixedToFloat, otRound, floatToFixed
      9 from fontTools.pens.boundsPen import BoundsPen
     10 from fontTools.ttLib import TTFont, newTable
     11 from fontTools.ttLib.tables import ttProgram
     12 from fontTools.ttLib.tables._g_l_y_f import GlyphCoordinates, flagOverlapSimple, OVERLAP_COMPOUND
     13 from fontTools.varLib import _GetCoordinates, _SetCoordinates
     14 from fontTools.varLib.models import (
     15 	supportScalar,
     16 	normalizeLocation,
     17 	piecewiseLinearMap,
     18 )
     19 from fontTools.varLib.merger import MutatorMerger
     20 from fontTools.varLib.varStore import VarStoreInstancer
     21 from fontTools.varLib.mvar import MVAR_ENTRIES
     22 from fontTools.varLib.iup import iup_delta
     23 import fontTools.subset.cff
     24 import os.path
     25 import logging
     26 
     27 
     28 log = logging.getLogger("fontTools.varlib.mutator")
     29 
     30 # map 'wdth' axis (1..200) to OS/2.usWidthClass (1..9), rounding to closest
     31 OS2_WIDTH_CLASS_VALUES = {}
     32 percents = [50.0, 62.5, 75.0, 87.5, 100.0, 112.5, 125.0, 150.0, 200.0]
     33 for 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 
     38 def 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 
     64 def 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 
    101 def 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 
    148 def 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 
    399 def 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 
    456 if __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