1""" 2Module for dealing with 'gvar'-style font variations, also known as run-time 3interpolation. 4 5The ideas here are very similar to MutatorMath. There is even code to read 6MutatorMath .designspace files in the varLib.designspace module. 7 8For now, if you run this file on a designspace file, it tries to find 9ttf-interpolatable files for the masters and build a variable-font from 10them. Such ttf-interpolatable and designspace files can be generated from 11a Glyphs source, eg., using noto-source as an example: 12 13 $ fontmake -o ttf-interpolatable -g NotoSansArabic-MM.glyphs 14 15Then you can make a variable-font this way: 16 17 $ fonttools varLib master_ufo/NotoSansArabic.designspace 18 19API *will* change in near future. 20""" 21from typing import List 22from fontTools.misc.vector import Vector 23from fontTools.misc.roundTools import noRound, otRound 24from fontTools.misc.textTools import Tag, tostr 25from fontTools.ttLib import TTFont, newTable 26from fontTools.ttLib.tables._f_v_a_r import Axis, NamedInstance 27from fontTools.ttLib.tables._g_l_y_f import GlyphCoordinates 28from fontTools.ttLib.tables.ttProgram import Program 29from fontTools.ttLib.tables.TupleVariation import TupleVariation 30from fontTools.ttLib.tables import otTables as ot 31from fontTools.ttLib.tables.otBase import OTTableWriter 32from fontTools.varLib import builder, models, varStore 33from fontTools.varLib.merger import VariationMerger, COLRVariationMerger 34from fontTools.varLib.mvar import MVAR_ENTRIES 35from fontTools.varLib.iup import iup_delta_optimize 36from fontTools.varLib.featureVars import addFeatureVariations 37from fontTools.designspaceLib import DesignSpaceDocument, InstanceDescriptor 38from fontTools.designspaceLib.split import splitInterpolable, splitVariableFonts 39from fontTools.varLib.stat import buildVFStatTable 40from fontTools.colorLib.builder import buildColrV1 41from fontTools.colorLib.unbuilder import unbuildColrV1 42from functools import partial 43from collections import OrderedDict, namedtuple 44import os.path 45import logging 46from copy import deepcopy 47from pprint import pformat 48from .errors import VarLibError, VarLibValidationError 49 50log = logging.getLogger("fontTools.varLib") 51 52# This is a lib key for the designspace document. The value should be 53# an OpenType feature tag, to be used as the FeatureVariations feature. 54# If present, the DesignSpace <rules processing="..."> flag is ignored. 55FEAVAR_FEATURETAG_LIB_KEY = "com.github.fonttools.varLib.featureVarsFeatureTag" 56 57# 58# Creation routines 59# 60 61def _add_fvar(font, axes, instances: List[InstanceDescriptor]): 62 """ 63 Add 'fvar' table to font. 64 65 axes is an ordered dictionary of DesignspaceAxis objects. 66 67 instances is list of dictionary objects with 'location', 'stylename', 68 and possibly 'postscriptfontname' entries. 69 """ 70 71 assert axes 72 assert isinstance(axes, OrderedDict) 73 74 log.info("Generating fvar") 75 76 fvar = newTable('fvar') 77 nameTable = font['name'] 78 79 for a in axes.values(): 80 axis = Axis() 81 axis.axisTag = Tag(a.tag) 82 # TODO Skip axes that have no variation. 83 axis.minValue, axis.defaultValue, axis.maxValue = a.minimum, a.default, a.maximum 84 axis.axisNameID = nameTable.addMultilingualName(a.labelNames, font, minNameID=256) 85 axis.flags = int(a.hidden) 86 fvar.axes.append(axis) 87 88 for instance in instances: 89 # Filter out discrete axis locations 90 coordinates = {name: value for name, value in instance.location.items() if name in axes} 91 92 if "en" not in instance.localisedStyleName: 93 if not instance.styleName: 94 raise VarLibValidationError( 95 f"Instance at location '{coordinates}' must have a default English " 96 "style name ('stylename' attribute on the instance element or a " 97 "stylename element with an 'xml:lang=\"en\"' attribute)." 98 ) 99 localisedStyleName = dict(instance.localisedStyleName) 100 localisedStyleName["en"] = tostr(instance.styleName) 101 else: 102 localisedStyleName = instance.localisedStyleName 103 104 psname = instance.postScriptFontName 105 106 inst = NamedInstance() 107 inst.subfamilyNameID = nameTable.addMultilingualName(localisedStyleName) 108 if psname is not None: 109 psname = tostr(psname) 110 inst.postscriptNameID = nameTable.addName(psname) 111 inst.coordinates = {axes[k].tag:axes[k].map_backward(v) for k,v in coordinates.items()} 112 #inst.coordinates = {axes[k].tag:v for k,v in coordinates.items()} 113 fvar.instances.append(inst) 114 115 assert "fvar" not in font 116 font['fvar'] = fvar 117 118 return fvar 119 120def _add_avar(font, axes): 121 """ 122 Add 'avar' table to font. 123 124 axes is an ordered dictionary of AxisDescriptor objects. 125 """ 126 127 assert axes 128 assert isinstance(axes, OrderedDict) 129 130 log.info("Generating avar") 131 132 avar = newTable('avar') 133 134 interesting = False 135 for axis in axes.values(): 136 # Currently, some rasterizers require that the default value maps 137 # (-1 to -1, 0 to 0, and 1 to 1) be present for all the segment 138 # maps, even when the default normalization mapping for the axis 139 # was not modified. 140 # https://github.com/googlei18n/fontmake/issues/295 141 # https://github.com/fonttools/fonttools/issues/1011 142 # TODO(anthrotype) revert this (and 19c4b37) when issue is fixed 143 curve = avar.segments[axis.tag] = {-1.0: -1.0, 0.0: 0.0, 1.0: 1.0} 144 if not axis.map: 145 continue 146 147 items = sorted(axis.map) 148 keys = [item[0] for item in items] 149 vals = [item[1] for item in items] 150 151 # Current avar requirements. We don't have to enforce 152 # these on the designer and can deduce some ourselves, 153 # but for now just enforce them. 154 if axis.minimum != min(keys): 155 raise VarLibValidationError( 156 f"Axis '{axis.name}': there must be a mapping for the axis minimum " 157 f"value {axis.minimum} and it must be the lowest input mapping value." 158 ) 159 if axis.maximum != max(keys): 160 raise VarLibValidationError( 161 f"Axis '{axis.name}': there must be a mapping for the axis maximum " 162 f"value {axis.maximum} and it must be the highest input mapping value." 163 ) 164 if axis.default not in keys: 165 raise VarLibValidationError( 166 f"Axis '{axis.name}': there must be a mapping for the axis default " 167 f"value {axis.default}." 168 ) 169 # No duplicate input values (output values can be >= their preceeding value). 170 if len(set(keys)) != len(keys): 171 raise VarLibValidationError( 172 f"Axis '{axis.name}': All axis mapping input='...' values must be " 173 "unique, but we found duplicates." 174 ) 175 # Ascending values 176 if sorted(vals) != vals: 177 raise VarLibValidationError( 178 f"Axis '{axis.name}': mapping output values must be in ascending order." 179 ) 180 181 keys_triple = (axis.minimum, axis.default, axis.maximum) 182 vals_triple = tuple(axis.map_forward(v) for v in keys_triple) 183 184 keys = [models.normalizeValue(v, keys_triple) for v in keys] 185 vals = [models.normalizeValue(v, vals_triple) for v in vals] 186 187 if all(k == v for k, v in zip(keys, vals)): 188 continue 189 interesting = True 190 191 curve.update(zip(keys, vals)) 192 193 assert 0.0 in curve and curve[0.0] == 0.0 194 assert -1.0 not in curve or curve[-1.0] == -1.0 195 assert +1.0 not in curve or curve[+1.0] == +1.0 196 # curve.update({-1.0: -1.0, 0.0: 0.0, 1.0: 1.0}) 197 198 assert "avar" not in font 199 if not interesting: 200 log.info("No need for avar") 201 avar = None 202 else: 203 font['avar'] = avar 204 205 return avar 206 207def _add_stat(font): 208 # Note: this function only gets called by old code that calls `build()` 209 # directly. Newer code that wants to benefit from STAT data from the 210 # designspace should call `build_many()` 211 212 if "STAT" in font: 213 return 214 215 from ..otlLib.builder import buildStatTable 216 fvarTable = font['fvar'] 217 axes = [dict(tag=a.axisTag, name=a.axisNameID) for a in fvarTable.axes] 218 buildStatTable(font, axes) 219 220_MasterData = namedtuple('_MasterData', ['glyf', 'hMetrics', 'vMetrics']) 221 222def _add_gvar(font, masterModel, master_ttfs, tolerance=0.5, optimize=True): 223 if tolerance < 0: 224 raise ValueError("`tolerance` must be a positive number.") 225 226 log.info("Generating gvar") 227 assert "gvar" not in font 228 gvar = font["gvar"] = newTable('gvar') 229 glyf = font['glyf'] 230 defaultMasterIndex = masterModel.reverseMapping[0] 231 232 master_datas = [_MasterData(m['glyf'], 233 m['hmtx'].metrics, 234 getattr(m.get('vmtx'), 'metrics', None)) 235 for m in master_ttfs] 236 237 for glyph in font.getGlyphOrder(): 238 log.debug("building gvar for glyph '%s'", glyph) 239 isComposite = glyf[glyph].isComposite() 240 241 allData = [ 242 m.glyf._getCoordinatesAndControls(glyph, m.hMetrics, m.vMetrics) 243 for m in master_datas 244 ] 245 246 if allData[defaultMasterIndex][1].numberOfContours != 0: 247 # If the default master is not empty, interpret empty non-default masters 248 # as missing glyphs from a sparse master 249 allData = [ 250 d if d is not None and d[1].numberOfContours != 0 else None 251 for d in allData 252 ] 253 254 model, allData = masterModel.getSubModel(allData) 255 256 allCoords = [d[0] for d in allData] 257 allControls = [d[1] for d in allData] 258 control = allControls[0] 259 if not models.allEqual(allControls): 260 log.warning("glyph %s has incompatible masters; skipping" % glyph) 261 continue 262 del allControls 263 264 # Update gvar 265 gvar.variations[glyph] = [] 266 deltas = model.getDeltas(allCoords, round=partial(GlyphCoordinates.__round__, round=round)) 267 supports = model.supports 268 assert len(deltas) == len(supports) 269 270 # Prepare for IUP optimization 271 origCoords = deltas[0] 272 endPts = control.endPts 273 274 for i,(delta,support) in enumerate(zip(deltas[1:], supports[1:])): 275 if all(v == 0 for v in delta.array) and not isComposite: 276 continue 277 var = TupleVariation(support, delta) 278 if optimize: 279 delta_opt = iup_delta_optimize(delta, origCoords, endPts, tolerance=tolerance) 280 281 if None in delta_opt: 282 """In composite glyphs, there should be one 0 entry 283 to make sure the gvar entry is written to the font. 284 285 This is to work around an issue with macOS 10.14 and can be 286 removed once the behaviour of macOS is changed. 287 288 https://github.com/fonttools/fonttools/issues/1381 289 """ 290 if all(d is None for d in delta_opt): 291 delta_opt = [(0, 0)] + [None] * (len(delta_opt) - 1) 292 # Use "optimized" version only if smaller... 293 var_opt = TupleVariation(support, delta_opt) 294 295 axis_tags = sorted(support.keys()) # Shouldn't matter that this is different from fvar...? 296 tupleData, auxData = var.compile(axis_tags) 297 unoptimized_len = len(tupleData) + len(auxData) 298 tupleData, auxData = var_opt.compile(axis_tags) 299 optimized_len = len(tupleData) + len(auxData) 300 301 if optimized_len < unoptimized_len: 302 var = var_opt 303 304 gvar.variations[glyph].append(var) 305 306 307def _remove_TTHinting(font): 308 for tag in ("cvar", "cvt ", "fpgm", "prep"): 309 if tag in font: 310 del font[tag] 311 maxp = font['maxp'] 312 for attr in ("maxTwilightPoints", "maxStorage", "maxFunctionDefs", "maxInstructionDefs", "maxStackElements", "maxSizeOfInstructions"): 313 setattr(maxp, attr, 0) 314 maxp.maxZones = 1 315 font["glyf"].removeHinting() 316 # TODO: Modify gasp table to deactivate gridfitting for all ranges? 317 318def _merge_TTHinting(font, masterModel, master_ttfs): 319 320 log.info("Merging TT hinting") 321 assert "cvar" not in font 322 323 # Check that the existing hinting is compatible 324 325 # fpgm and prep table 326 327 for tag in ("fpgm", "prep"): 328 all_pgms = [m[tag].program for m in master_ttfs if tag in m] 329 if not all_pgms: 330 continue 331 font_pgm = getattr(font.get(tag), 'program', None) 332 if any(pgm != font_pgm for pgm in all_pgms): 333 log.warning("Masters have incompatible %s tables, hinting is discarded." % tag) 334 _remove_TTHinting(font) 335 return 336 337 # glyf table 338 339 font_glyf = font['glyf'] 340 master_glyfs = [m['glyf'] for m in master_ttfs] 341 for name, glyph in font_glyf.glyphs.items(): 342 all_pgms = [ 343 getattr(glyf.get(name), 'program', None) 344 for glyf in master_glyfs 345 ] 346 if not any(all_pgms): 347 continue 348 glyph.expand(font_glyf) 349 font_pgm = getattr(glyph, 'program', None) 350 if any(pgm != font_pgm for pgm in all_pgms if pgm): 351 log.warning("Masters have incompatible glyph programs in glyph '%s', hinting is discarded." % name) 352 # TODO Only drop hinting from this glyph. 353 _remove_TTHinting(font) 354 return 355 356 # cvt table 357 358 all_cvs = [Vector(m["cvt "].values) if 'cvt ' in m else None 359 for m in master_ttfs] 360 361 nonNone_cvs = models.nonNone(all_cvs) 362 if not nonNone_cvs: 363 # There is no cvt table to make a cvar table from, we're done here. 364 return 365 366 if not models.allEqual(len(c) for c in nonNone_cvs): 367 log.warning("Masters have incompatible cvt tables, hinting is discarded.") 368 _remove_TTHinting(font) 369 return 370 371 variations = [] 372 deltas, supports = masterModel.getDeltasAndSupports(all_cvs, round=round) # builtin round calls into Vector.__round__, which uses builtin round as we like 373 for i,(delta,support) in enumerate(zip(deltas[1:], supports[1:])): 374 if all(v == 0 for v in delta): 375 continue 376 var = TupleVariation(support, delta) 377 variations.append(var) 378 379 # We can build the cvar table now. 380 if variations: 381 cvar = font["cvar"] = newTable('cvar') 382 cvar.version = 1 383 cvar.variations = variations 384 385 386_MetricsFields = namedtuple('_MetricsFields', 387 ['tableTag', 'metricsTag', 'sb1', 'sb2', 'advMapping', 'vOrigMapping']) 388 389HVAR_FIELDS = _MetricsFields(tableTag='HVAR', metricsTag='hmtx', sb1='LsbMap', 390 sb2='RsbMap', advMapping='AdvWidthMap', vOrigMapping=None) 391 392VVAR_FIELDS = _MetricsFields(tableTag='VVAR', metricsTag='vmtx', sb1='TsbMap', 393 sb2='BsbMap', advMapping='AdvHeightMap', vOrigMapping='VOrgMap') 394 395def _add_HVAR(font, masterModel, master_ttfs, axisTags): 396 _add_VHVAR(font, masterModel, master_ttfs, axisTags, HVAR_FIELDS) 397 398def _add_VVAR(font, masterModel, master_ttfs, axisTags): 399 _add_VHVAR(font, masterModel, master_ttfs, axisTags, VVAR_FIELDS) 400 401def _add_VHVAR(font, masterModel, master_ttfs, axisTags, tableFields): 402 403 tableTag = tableFields.tableTag 404 assert tableTag not in font 405 log.info("Generating " + tableTag) 406 VHVAR = newTable(tableTag) 407 tableClass = getattr(ot, tableTag) 408 vhvar = VHVAR.table = tableClass() 409 vhvar.Version = 0x00010000 410 411 glyphOrder = font.getGlyphOrder() 412 413 # Build list of source font advance widths for each glyph 414 metricsTag = tableFields.metricsTag 415 advMetricses = [m[metricsTag].metrics for m in master_ttfs] 416 417 # Build list of source font vertical origin coords for each glyph 418 if tableTag == 'VVAR' and 'VORG' in master_ttfs[0]: 419 vOrigMetricses = [m['VORG'].VOriginRecords for m in master_ttfs] 420 defaultYOrigs = [m['VORG'].defaultVertOriginY for m in master_ttfs] 421 vOrigMetricses = list(zip(vOrigMetricses, defaultYOrigs)) 422 else: 423 vOrigMetricses = None 424 425 metricsStore, advanceMapping, vOrigMapping = _get_advance_metrics(font, 426 masterModel, master_ttfs, axisTags, glyphOrder, advMetricses, 427 vOrigMetricses) 428 429 vhvar.VarStore = metricsStore 430 if advanceMapping is None: 431 setattr(vhvar, tableFields.advMapping, None) 432 else: 433 setattr(vhvar, tableFields.advMapping, advanceMapping) 434 if vOrigMapping is not None: 435 setattr(vhvar, tableFields.vOrigMapping, vOrigMapping) 436 setattr(vhvar, tableFields.sb1, None) 437 setattr(vhvar, tableFields.sb2, None) 438 439 font[tableTag] = VHVAR 440 return 441 442def _get_advance_metrics(font, masterModel, master_ttfs, 443 axisTags, glyphOrder, advMetricses, vOrigMetricses=None): 444 445 vhAdvanceDeltasAndSupports = {} 446 vOrigDeltasAndSupports = {} 447 for glyph in glyphOrder: 448 vhAdvances = [metrics[glyph][0] if glyph in metrics else None for metrics in advMetricses] 449 vhAdvanceDeltasAndSupports[glyph] = masterModel.getDeltasAndSupports(vhAdvances, round=round) 450 451 singleModel = models.allEqual(id(v[1]) for v in vhAdvanceDeltasAndSupports.values()) 452 453 if vOrigMetricses: 454 singleModel = False 455 for glyph in glyphOrder: 456 # We need to supply a vOrigs tuple with non-None default values 457 # for each glyph. vOrigMetricses contains values only for those 458 # glyphs which have a non-default vOrig. 459 vOrigs = [metrics[glyph] if glyph in metrics else defaultVOrig 460 for metrics, defaultVOrig in vOrigMetricses] 461 vOrigDeltasAndSupports[glyph] = masterModel.getDeltasAndSupports(vOrigs, round=round) 462 463 directStore = None 464 if singleModel: 465 # Build direct mapping 466 supports = next(iter(vhAdvanceDeltasAndSupports.values()))[1][1:] 467 varTupleList = builder.buildVarRegionList(supports, axisTags) 468 varTupleIndexes = list(range(len(supports))) 469 varData = builder.buildVarData(varTupleIndexes, [], optimize=False) 470 for glyphName in glyphOrder: 471 varData.addItem(vhAdvanceDeltasAndSupports[glyphName][0], round=noRound) 472 varData.optimize() 473 directStore = builder.buildVarStore(varTupleList, [varData]) 474 475 # Build optimized indirect mapping 476 storeBuilder = varStore.OnlineVarStoreBuilder(axisTags) 477 advMapping = {} 478 for glyphName in glyphOrder: 479 deltas, supports = vhAdvanceDeltasAndSupports[glyphName] 480 storeBuilder.setSupports(supports) 481 advMapping[glyphName] = storeBuilder.storeDeltas(deltas, round=noRound) 482 483 if vOrigMetricses: 484 vOrigMap = {} 485 for glyphName in glyphOrder: 486 deltas, supports = vOrigDeltasAndSupports[glyphName] 487 storeBuilder.setSupports(supports) 488 vOrigMap[glyphName] = storeBuilder.storeDeltas(deltas, round=noRound) 489 490 indirectStore = storeBuilder.finish() 491 mapping2 = indirectStore.optimize(use_NO_VARIATION_INDEX=False) 492 advMapping = [mapping2[advMapping[g]] for g in glyphOrder] 493 advanceMapping = builder.buildVarIdxMap(advMapping, glyphOrder) 494 495 if vOrigMetricses: 496 vOrigMap = [mapping2[vOrigMap[g]] for g in glyphOrder] 497 498 useDirect = False 499 vOrigMapping = None 500 if directStore: 501 # Compile both, see which is more compact 502 503 writer = OTTableWriter() 504 directStore.compile(writer, font) 505 directSize = len(writer.getAllData()) 506 507 writer = OTTableWriter() 508 indirectStore.compile(writer, font) 509 advanceMapping.compile(writer, font) 510 indirectSize = len(writer.getAllData()) 511 512 useDirect = directSize < indirectSize 513 514 if useDirect: 515 metricsStore = directStore 516 advanceMapping = None 517 else: 518 metricsStore = indirectStore 519 if vOrigMetricses: 520 vOrigMapping = builder.buildVarIdxMap(vOrigMap, glyphOrder) 521 522 return metricsStore, advanceMapping, vOrigMapping 523 524def _add_MVAR(font, masterModel, master_ttfs, axisTags): 525 526 log.info("Generating MVAR") 527 528 store_builder = varStore.OnlineVarStoreBuilder(axisTags) 529 530 records = [] 531 lastTableTag = None 532 fontTable = None 533 tables = None 534 # HACK: we need to special-case post.underlineThickness and .underlinePosition 535 # and unilaterally/arbitrarily define a sentinel value to distinguish the case 536 # when a post table is present in a given master simply because that's where 537 # the glyph names in TrueType must be stored, but the underline values are not 538 # meant to be used for building MVAR's deltas. The value of -0x8000 (-36768) 539 # the minimum FWord (int16) value, was chosen for its unlikelyhood to appear 540 # in real-world underline position/thickness values. 541 specialTags = {"unds": -0x8000, "undo": -0x8000} 542 543 for tag, (tableTag, itemName) in sorted(MVAR_ENTRIES.items(), key=lambda kv: kv[1]): 544 # For each tag, fetch the associated table from all fonts (or not when we are 545 # still looking at a tag from the same tables) and set up the variation model 546 # for them. 547 if tableTag != lastTableTag: 548 tables = fontTable = None 549 if tableTag in font: 550 fontTable = font[tableTag] 551 tables = [] 552 for master in master_ttfs: 553 if tableTag not in master or ( 554 tag in specialTags 555 and getattr(master[tableTag], itemName) == specialTags[tag] 556 ): 557 tables.append(None) 558 else: 559 tables.append(master[tableTag]) 560 model, tables = masterModel.getSubModel(tables) 561 store_builder.setModel(model) 562 lastTableTag = tableTag 563 564 if tables is None: # Tag not applicable to the master font. 565 continue 566 567 # TODO support gasp entries 568 569 master_values = [getattr(table, itemName) for table in tables] 570 if models.allEqual(master_values): 571 base, varIdx = master_values[0], None 572 else: 573 base, varIdx = store_builder.storeMasters(master_values) 574 setattr(fontTable, itemName, base) 575 576 if varIdx is None: 577 continue 578 log.info(' %s: %s.%s %s', tag, tableTag, itemName, master_values) 579 rec = ot.MetricsValueRecord() 580 rec.ValueTag = tag 581 rec.VarIdx = varIdx 582 records.append(rec) 583 584 assert "MVAR" not in font 585 if records: 586 store = store_builder.finish() 587 # Optimize 588 mapping = store.optimize() 589 for rec in records: 590 rec.VarIdx = mapping[rec.VarIdx] 591 592 MVAR = font["MVAR"] = newTable('MVAR') 593 mvar = MVAR.table = ot.MVAR() 594 mvar.Version = 0x00010000 595 mvar.Reserved = 0 596 mvar.VarStore = store 597 # XXX these should not be hard-coded but computed automatically 598 mvar.ValueRecordSize = 8 599 mvar.ValueRecordCount = len(records) 600 mvar.ValueRecord = sorted(records, key=lambda r: r.ValueTag) 601 602 603def _add_BASE(font, masterModel, master_ttfs, axisTags): 604 605 log.info("Generating BASE") 606 607 merger = VariationMerger(masterModel, axisTags, font) 608 merger.mergeTables(font, master_ttfs, ['BASE']) 609 store = merger.store_builder.finish() 610 611 if not store: 612 return 613 base = font['BASE'].table 614 assert base.Version == 0x00010000 615 base.Version = 0x00010001 616 base.VarStore = store 617 618 619def _merge_OTL(font, model, master_fonts, axisTags): 620 621 log.info("Merging OpenType Layout tables") 622 merger = VariationMerger(model, axisTags, font) 623 624 merger.mergeTables(font, master_fonts, ['GSUB', 'GDEF', 'GPOS']) 625 store = merger.store_builder.finish() 626 if not store: 627 return 628 try: 629 GDEF = font['GDEF'].table 630 assert GDEF.Version <= 0x00010002 631 except KeyError: 632 font['GDEF'] = newTable('GDEF') 633 GDEFTable = font["GDEF"] = newTable('GDEF') 634 GDEF = GDEFTable.table = ot.GDEF() 635 GDEF.GlyphClassDef = None 636 GDEF.AttachList = None 637 GDEF.LigCaretList = None 638 GDEF.MarkAttachClassDef = None 639 GDEF.MarkGlyphSetsDef = None 640 641 GDEF.Version = 0x00010003 642 GDEF.VarStore = store 643 644 # Optimize 645 varidx_map = store.optimize() 646 GDEF.remap_device_varidxes(varidx_map) 647 if 'GPOS' in font: 648 font['GPOS'].table.remap_device_varidxes(varidx_map) 649 650 651def _add_GSUB_feature_variations(font, axes, internal_axis_supports, rules, featureTag): 652 653 def normalize(name, value): 654 return models.normalizeLocation( 655 {name: value}, internal_axis_supports 656 )[name] 657 658 log.info("Generating GSUB FeatureVariations") 659 660 axis_tags = {name: axis.tag for name, axis in axes.items()} 661 662 conditional_subs = [] 663 for rule in rules: 664 665 region = [] 666 for conditions in rule.conditionSets: 667 space = {} 668 for condition in conditions: 669 axis_name = condition["name"] 670 if condition["minimum"] is not None: 671 minimum = normalize(axis_name, condition["minimum"]) 672 else: 673 minimum = -1.0 674 if condition["maximum"] is not None: 675 maximum = normalize(axis_name, condition["maximum"]) 676 else: 677 maximum = 1.0 678 tag = axis_tags[axis_name] 679 space[tag] = (minimum, maximum) 680 region.append(space) 681 682 subs = {k: v for k, v in rule.subs} 683 684 conditional_subs.append((region, subs)) 685 686 addFeatureVariations(font, conditional_subs, featureTag) 687 688 689_DesignSpaceData = namedtuple( 690 "_DesignSpaceData", 691 [ 692 "axes", 693 "internal_axis_supports", 694 "base_idx", 695 "normalized_master_locs", 696 "masters", 697 "instances", 698 "rules", 699 "rulesProcessingLast", 700 "lib", 701 ], 702) 703 704 705def _add_CFF2(varFont, model, master_fonts): 706 from .cff import merge_region_fonts 707 glyphOrder = varFont.getGlyphOrder() 708 if "CFF2" not in varFont: 709 from .cff import convertCFFtoCFF2 710 convertCFFtoCFF2(varFont) 711 ordered_fonts_list = model.reorderMasters(master_fonts, model.reverseMapping) 712 # re-ordering the master list simplifies building the CFF2 data item lists. 713 merge_region_fonts(varFont, model, ordered_fonts_list, glyphOrder) 714 715 716def _add_COLR(font, model, master_fonts, axisTags, colr_layer_reuse=True): 717 merger = COLRVariationMerger(model, axisTags, font, allowLayerReuse=colr_layer_reuse) 718 merger.mergeTables(font, master_fonts) 719 store = merger.store_builder.finish() 720 721 colr = font["COLR"].table 722 if store: 723 mapping = store.optimize() 724 colr.VarStore = store 725 varIdxes = [mapping[v] for v in merger.varIdxes] 726 colr.VarIndexMap = builder.buildDeltaSetIndexMap(varIdxes) 727 728 729def load_designspace(designspace): 730 # TODO: remove this and always assume 'designspace' is a DesignSpaceDocument, 731 # never a file path, as that's already handled by caller 732 if hasattr(designspace, "sources"): # Assume a DesignspaceDocument 733 ds = designspace 734 else: # Assume a file path 735 ds = DesignSpaceDocument.fromfile(designspace) 736 737 masters = ds.sources 738 if not masters: 739 raise VarLibValidationError("Designspace must have at least one source.") 740 instances = ds.instances 741 742 # TODO: Use fontTools.designspaceLib.tagForAxisName instead. 743 standard_axis_map = OrderedDict([ 744 ('weight', ('wght', {'en': u'Weight'})), 745 ('width', ('wdth', {'en': u'Width'})), 746 ('slant', ('slnt', {'en': u'Slant'})), 747 ('optical', ('opsz', {'en': u'Optical Size'})), 748 ('italic', ('ital', {'en': u'Italic'})), 749 ]) 750 751 # Setup axes 752 if not ds.axes: 753 raise VarLibValidationError(f"Designspace must have at least one axis.") 754 755 axes = OrderedDict() 756 for axis_index, axis in enumerate(ds.axes): 757 axis_name = axis.name 758 if not axis_name: 759 if not axis.tag: 760 raise VarLibValidationError(f"Axis at index {axis_index} needs a tag.") 761 axis_name = axis.name = axis.tag 762 763 if axis_name in standard_axis_map: 764 if axis.tag is None: 765 axis.tag = standard_axis_map[axis_name][0] 766 if not axis.labelNames: 767 axis.labelNames.update(standard_axis_map[axis_name][1]) 768 else: 769 if not axis.tag: 770 raise VarLibValidationError(f"Axis at index {axis_index} needs a tag.") 771 if not axis.labelNames: 772 axis.labelNames["en"] = tostr(axis_name) 773 774 axes[axis_name] = axis 775 log.info("Axes:\n%s", pformat([axis.asdict() for axis in axes.values()])) 776 777 # Check all master and instance locations are valid and fill in defaults 778 for obj in masters+instances: 779 obj_name = obj.name or obj.styleName or '' 780 loc = obj.getFullDesignLocation(ds) 781 obj.designLocation = loc 782 if loc is None: 783 raise VarLibValidationError( 784 f"Source or instance '{obj_name}' has no location." 785 ) 786 for axis_name in loc.keys(): 787 if axis_name not in axes: 788 raise VarLibValidationError( 789 f"Location axis '{axis_name}' unknown for '{obj_name}'." 790 ) 791 for axis_name,axis in axes.items(): 792 v = axis.map_backward(loc[axis_name]) 793 if not (axis.minimum <= v <= axis.maximum): 794 raise VarLibValidationError( 795 f"Source or instance '{obj_name}' has out-of-range location " 796 f"for axis '{axis_name}': is mapped to {v} but must be in " 797 f"mapped range [{axis.minimum}..{axis.maximum}] (NOTE: all " 798 "values are in user-space)." 799 ) 800 801 # Normalize master locations 802 803 internal_master_locs = [o.getFullDesignLocation(ds) for o in masters] 804 log.info("Internal master locations:\n%s", pformat(internal_master_locs)) 805 806 # TODO This mapping should ideally be moved closer to logic in _add_fvar/avar 807 internal_axis_supports = {} 808 for axis in axes.values(): 809 triple = (axis.minimum, axis.default, axis.maximum) 810 internal_axis_supports[axis.name] = [axis.map_forward(v) for v in triple] 811 log.info("Internal axis supports:\n%s", pformat(internal_axis_supports)) 812 813 normalized_master_locs = [models.normalizeLocation(m, internal_axis_supports) for m in internal_master_locs] 814 log.info("Normalized master locations:\n%s", pformat(normalized_master_locs)) 815 816 # Find base master 817 base_idx = None 818 for i,m in enumerate(normalized_master_locs): 819 if all(v == 0 for v in m.values()): 820 if base_idx is not None: 821 raise VarLibValidationError( 822 "More than one base master found in Designspace." 823 ) 824 base_idx = i 825 if base_idx is None: 826 raise VarLibValidationError( 827 "Base master not found; no master at default location?" 828 ) 829 log.info("Index of base master: %s", base_idx) 830 831 return _DesignSpaceData( 832 axes, 833 internal_axis_supports, 834 base_idx, 835 normalized_master_locs, 836 masters, 837 instances, 838 ds.rules, 839 ds.rulesProcessingLast, 840 ds.lib, 841 ) 842 843 844# https://docs.microsoft.com/en-us/typography/opentype/spec/os2#uswidthclass 845WDTH_VALUE_TO_OS2_WIDTH_CLASS = { 846 50: 1, 847 62.5: 2, 848 75: 3, 849 87.5: 4, 850 100: 5, 851 112.5: 6, 852 125: 7, 853 150: 8, 854 200: 9, 855} 856 857 858def set_default_weight_width_slant(font, location): 859 if "OS/2" in font: 860 if "wght" in location: 861 weight_class = otRound(max(1, min(location["wght"], 1000))) 862 if font["OS/2"].usWeightClass != weight_class: 863 log.info("Setting OS/2.usWeightClass = %s", weight_class) 864 font["OS/2"].usWeightClass = weight_class 865 866 if "wdth" in location: 867 # map 'wdth' axis (50..200) to OS/2.usWidthClass (1..9), rounding to closest 868 widthValue = min(max(location["wdth"], 50), 200) 869 widthClass = otRound( 870 models.piecewiseLinearMap(widthValue, WDTH_VALUE_TO_OS2_WIDTH_CLASS) 871 ) 872 if font["OS/2"].usWidthClass != widthClass: 873 log.info("Setting OS/2.usWidthClass = %s", widthClass) 874 font["OS/2"].usWidthClass = widthClass 875 876 if "slnt" in location and "post" in font: 877 italicAngle = max(-90, min(location["slnt"], 90)) 878 if font["post"].italicAngle != italicAngle: 879 log.info("Setting post.italicAngle = %s", italicAngle) 880 font["post"].italicAngle = italicAngle 881 882 883def build_many( 884 designspace: DesignSpaceDocument, 885 master_finder=lambda s:s, 886 exclude=[], 887 optimize=True, 888 skip_vf=lambda vf_name: False, 889 colr_layer_reuse=True, 890): 891 """ 892 Build variable fonts from a designspace file, version 5 which can define 893 several VFs, or version 4 which has implicitly one VF covering the whole doc. 894 895 If master_finder is set, it should be a callable that takes master 896 filename as found in designspace file and map it to master font 897 binary as to be opened (eg. .ttf or .otf). 898 899 skip_vf can be used to skip building some of the variable fonts defined in 900 the input designspace. It's a predicate that takes as argument the name 901 of the variable font and returns `bool`. 902 903 Always returns a Dict[str, TTFont] keyed by VariableFontDescriptor.name 904 """ 905 res = {} 906 for _location, subDoc in splitInterpolable(designspace): 907 for name, vfDoc in splitVariableFonts(subDoc): 908 if skip_vf(name): 909 log.debug(f"Skipping variable TTF font: {name}") 910 continue 911 vf = build( 912 vfDoc, 913 master_finder, 914 exclude=list(exclude) + ["STAT"], 915 optimize=optimize, 916 colr_layer_reuse=colr_layer_reuse, 917 )[0] 918 if "STAT" not in exclude: 919 buildVFStatTable(vf, designspace, name) 920 res[name] = vf 921 return res 922 923def build( 924 designspace, 925 master_finder=lambda s:s, 926 exclude=[], 927 optimize=True, 928 colr_layer_reuse=True, 929): 930 """ 931 Build variation font from a designspace file. 932 933 If master_finder is set, it should be a callable that takes master 934 filename as found in designspace file and map it to master font 935 binary as to be opened (eg. .ttf or .otf). 936 """ 937 if hasattr(designspace, "sources"): # Assume a DesignspaceDocument 938 pass 939 else: # Assume a file path 940 designspace = DesignSpaceDocument.fromfile(designspace) 941 942 ds = load_designspace(designspace) 943 log.info("Building variable font") 944 945 log.info("Loading master fonts") 946 master_fonts = load_masters(designspace, master_finder) 947 948 # TODO: 'master_ttfs' is unused except for return value, remove later 949 master_ttfs = [] 950 for master in master_fonts: 951 try: 952 master_ttfs.append(master.reader.file.name) 953 except AttributeError: 954 master_ttfs.append(None) # in-memory fonts have no path 955 956 # Copy the base master to work from it 957 vf = deepcopy(master_fonts[ds.base_idx]) 958 959 # TODO append masters as named-instances as well; needs .designspace change. 960 fvar = _add_fvar(vf, ds.axes, ds.instances) 961 if 'STAT' not in exclude: 962 _add_stat(vf) 963 if 'avar' not in exclude: 964 _add_avar(vf, ds.axes) 965 966 # Map from axis names to axis tags... 967 normalized_master_locs = [ 968 {ds.axes[k].tag: v for k,v in loc.items()} for loc in ds.normalized_master_locs 969 ] 970 # From here on, we use fvar axes only 971 axisTags = [axis.axisTag for axis in fvar.axes] 972 973 # Assume single-model for now. 974 model = models.VariationModel(normalized_master_locs, axisOrder=axisTags) 975 assert 0 == model.mapping[ds.base_idx] 976 977 log.info("Building variations tables") 978 if 'BASE' not in exclude and 'BASE' in vf: 979 _add_BASE(vf, model, master_fonts, axisTags) 980 if 'MVAR' not in exclude: 981 _add_MVAR(vf, model, master_fonts, axisTags) 982 if 'HVAR' not in exclude: 983 _add_HVAR(vf, model, master_fonts, axisTags) 984 if 'VVAR' not in exclude and 'vmtx' in vf: 985 _add_VVAR(vf, model, master_fonts, axisTags) 986 if 'GDEF' not in exclude or 'GPOS' not in exclude: 987 _merge_OTL(vf, model, master_fonts, axisTags) 988 if 'gvar' not in exclude and 'glyf' in vf: 989 _add_gvar(vf, model, master_fonts, optimize=optimize) 990 if 'cvar' not in exclude and 'glyf' in vf: 991 _merge_TTHinting(vf, model, master_fonts) 992 if 'GSUB' not in exclude and ds.rules: 993 featureTag = ds.lib.get( 994 FEAVAR_FEATURETAG_LIB_KEY, 995 "rclt" if ds.rulesProcessingLast else "rvrn" 996 ) 997 _add_GSUB_feature_variations(vf, ds.axes, ds.internal_axis_supports, ds.rules, featureTag) 998 if 'CFF2' not in exclude and ('CFF ' in vf or 'CFF2' in vf): 999 _add_CFF2(vf, model, master_fonts) 1000 if "post" in vf: 1001 # set 'post' to format 2 to keep the glyph names dropped from CFF2 1002 post = vf["post"] 1003 if post.formatType != 2.0: 1004 post.formatType = 2.0 1005 post.extraNames = [] 1006 post.mapping = {} 1007 if 'COLR' not in exclude and 'COLR' in vf and vf['COLR'].version > 0: 1008 _add_COLR(vf, model, master_fonts, axisTags, colr_layer_reuse) 1009 1010 set_default_weight_width_slant( 1011 vf, location={axis.axisTag: axis.defaultValue for axis in vf["fvar"].axes} 1012 ) 1013 1014 for tag in exclude: 1015 if tag in vf: 1016 del vf[tag] 1017 1018 # TODO: Only return vf for 4.0+, the rest is unused. 1019 return vf, model, master_ttfs 1020 1021 1022def _open_font(path, master_finder=lambda s: s): 1023 # load TTFont masters from given 'path': this can be either a .TTX or an 1024 # OpenType binary font; or if neither of these, try use the 'master_finder' 1025 # callable to resolve the path to a valid .TTX or OpenType font binary. 1026 from fontTools.ttx import guessFileType 1027 1028 master_path = os.path.normpath(path) 1029 tp = guessFileType(master_path) 1030 if tp is None: 1031 # not an OpenType binary/ttx, fall back to the master finder. 1032 master_path = master_finder(master_path) 1033 tp = guessFileType(master_path) 1034 if tp in ("TTX", "OTX"): 1035 font = TTFont() 1036 font.importXML(master_path) 1037 elif tp in ("TTF", "OTF", "WOFF", "WOFF2"): 1038 font = TTFont(master_path) 1039 else: 1040 raise VarLibValidationError("Invalid master path: %r" % master_path) 1041 return font 1042 1043 1044def load_masters(designspace, master_finder=lambda s: s): 1045 """Ensure that all SourceDescriptor.font attributes have an appropriate TTFont 1046 object loaded, or else open TTFont objects from the SourceDescriptor.path 1047 attributes. 1048 1049 The paths can point to either an OpenType font, a TTX file, or a UFO. In the 1050 latter case, use the provided master_finder callable to map from UFO paths to 1051 the respective master font binaries (e.g. .ttf, .otf or .ttx). 1052 1053 Return list of master TTFont objects in the same order they are listed in the 1054 DesignSpaceDocument. 1055 """ 1056 for master in designspace.sources: 1057 # If a SourceDescriptor has a layer name, demand that the compiled TTFont 1058 # be supplied by the caller. This spares us from modifying MasterFinder. 1059 if master.layerName and master.font is None: 1060 raise VarLibValidationError( 1061 f"Designspace source '{master.name or '<Unknown>'}' specified a " 1062 "layer name but lacks the required TTFont object in the 'font' " 1063 "attribute." 1064 ) 1065 1066 return designspace.loadSourceFonts(_open_font, master_finder=master_finder) 1067 1068 1069class MasterFinder(object): 1070 1071 def __init__(self, template): 1072 self.template = template 1073 1074 def __call__(self, src_path): 1075 fullname = os.path.abspath(src_path) 1076 dirname, basename = os.path.split(fullname) 1077 stem, ext = os.path.splitext(basename) 1078 path = self.template.format( 1079 fullname=fullname, 1080 dirname=dirname, 1081 basename=basename, 1082 stem=stem, 1083 ext=ext, 1084 ) 1085 return os.path.normpath(path) 1086 1087 1088def main(args=None): 1089 """Build a variable font from a designspace file and masters""" 1090 from argparse import ArgumentParser 1091 from fontTools import configLogger 1092 1093 parser = ArgumentParser(prog='varLib', description = main.__doc__) 1094 parser.add_argument('designspace') 1095 parser.add_argument( 1096 '-o', 1097 metavar='OUTPUTFILE', 1098 dest='outfile', 1099 default=None, 1100 help='output file' 1101 ) 1102 parser.add_argument( 1103 '-x', 1104 metavar='TAG', 1105 dest='exclude', 1106 action='append', 1107 default=[], 1108 help='exclude table' 1109 ) 1110 parser.add_argument( 1111 '--disable-iup', 1112 dest='optimize', 1113 action='store_false', 1114 help='do not perform IUP optimization' 1115 ) 1116 parser.add_argument( 1117 '--no-colr-layer-reuse', 1118 dest='colr_layer_reuse', 1119 action='store_false', 1120 help='do not rebuild variable COLR table to optimize COLR layer reuse', 1121 ) 1122 parser.add_argument( 1123 '--master-finder', 1124 default='master_ttf_interpolatable/{stem}.ttf', 1125 help=( 1126 'templated string used for finding binary font ' 1127 'files given the source file names defined in the ' 1128 'designspace document. The following special strings ' 1129 'are defined: {fullname} is the absolute source file ' 1130 'name; {basename} is the file name without its ' 1131 'directory; {stem} is the basename without the file ' 1132 'extension; {ext} is the source file extension; ' 1133 '{dirname} is the directory of the absolute file ' 1134 'name. The default value is "%(default)s".' 1135 ) 1136 ) 1137 logging_group = parser.add_mutually_exclusive_group(required=False) 1138 logging_group.add_argument( 1139 "-v", "--verbose", 1140 action="store_true", 1141 help="Run more verbosely.") 1142 logging_group.add_argument( 1143 "-q", "--quiet", 1144 action="store_true", 1145 help="Turn verbosity off.") 1146 options = parser.parse_args(args) 1147 1148 configLogger(level=( 1149 "DEBUG" if options.verbose else 1150 "ERROR" if options.quiet else 1151 "INFO")) 1152 1153 designspace_filename = options.designspace 1154 finder = MasterFinder(options.master_finder) 1155 1156 vf, _, _ = build( 1157 designspace_filename, 1158 finder, 1159 exclude=options.exclude, 1160 optimize=options.optimize, 1161 colr_layer_reuse=options.colr_layer_reuse, 1162 ) 1163 1164 outfile = options.outfile 1165 if outfile is None: 1166 ext = "otf" if vf.sfntVersion == "OTTO" else "ttf" 1167 outfile = os.path.splitext(designspace_filename)[0] + '-VF.' + ext 1168 1169 log.info("Saving variation font %s", outfile) 1170 vf.save(outfile) 1171 1172 1173if __name__ == "__main__": 1174 import sys 1175 if len(sys.argv) > 1: 1176 sys.exit(main()) 1177 import doctest 1178 sys.exit(doctest.testmod().failed) 1179