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