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