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