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