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