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