Coverage for /usr/lib/python3/dist-packages/fontTools/varLib/__init__.py: 9%

739 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-14 15:55 +0200

1""" 

2Module for dealing with 'gvar'-style font variations, also known as run-time 

3interpolation. 

4 

5The ideas here are very similar to MutatorMath. There is even code to read 

6MutatorMath .designspace files in the varLib.designspace module. 

7 

8For now, if you run this file on a designspace file, it tries to find 

9ttf-interpolatable files for the masters and build a variable-font from 

10them. Such ttf-interpolatable and designspace files can be generated from 

11a Glyphs source, eg., using noto-source as an example: 

12 

13 $ fontmake -o ttf-interpolatable -g NotoSansArabic-MM.glyphs 

14 

15Then you can make a variable-font this way: 

16 

17 $ fonttools varLib master_ufo/NotoSansArabic.designspace 

18 

19API *will* change in near future. 

20""" 

21from typing import List 

22from fontTools.misc.vector import Vector 

23from fontTools.misc.roundTools import noRound, otRound 

24from fontTools.misc.fixedTools import floatToFixed as fl2fi 

25from fontTools.misc.textTools import Tag, tostr 

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, dropImpliedOnCurvePoints 

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, COLRVariationMerger 

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, InstanceDescriptor 

39from fontTools.designspaceLib.split import splitInterpolable, splitVariableFonts 

40from fontTools.varLib.stat import buildVFStatTable 

41from fontTools.colorLib.builder import buildColrV1 

42from fontTools.colorLib.unbuilder import unbuildColrV1 

43from functools import partial 

44from collections import OrderedDict, defaultdict, namedtuple 

45import os.path 

46import logging 

47from copy import deepcopy 

48from pprint import pformat 

49from re import fullmatch 

50from .errors import VarLibError, VarLibValidationError 

51 

52log = logging.getLogger("fontTools.varLib") 

53 

54# This is a lib key for the designspace document. The value should be 

55# a comma-separated list of OpenType feature tag(s), to be used as the 

56# FeatureVariations feature. 

57# If present, the DesignSpace <rules processing="..."> flag is ignored. 

58FEAVAR_FEATURETAG_LIB_KEY = "com.github.fonttools.varLib.featureVarsFeatureTag" 

59 

60# 

61# Creation routines 

62# 

63 

64 

65def _add_fvar(font, axes, instances: List[InstanceDescriptor]): 

66 """ 

67 Add 'fvar' table to font. 

68 

69 axes is an ordered dictionary of DesignspaceAxis objects. 

70 

71 instances is list of dictionary objects with 'location', 'stylename', 

72 and possibly 'postscriptfontname' entries. 

73 """ 

74 

75 assert axes 

76 assert isinstance(axes, OrderedDict) 

77 

78 log.info("Generating fvar") 

79 

80 fvar = newTable("fvar") 

81 nameTable = font["name"] 

82 

83 for a in axes.values(): 

84 axis = Axis() 

85 axis.axisTag = Tag(a.tag) 

86 # TODO Skip axes that have no variation. 

87 axis.minValue, axis.defaultValue, axis.maxValue = ( 

88 a.minimum, 

89 a.default, 

90 a.maximum, 

91 ) 

92 axis.axisNameID = nameTable.addMultilingualName( 

93 a.labelNames, font, minNameID=256 

94 ) 

95 axis.flags = int(a.hidden) 

96 fvar.axes.append(axis) 

97 

98 for instance in instances: 

99 # Filter out discrete axis locations 

100 coordinates = { 

101 name: value for name, value in instance.location.items() if name in axes 

102 } 

103 

104 if "en" not in instance.localisedStyleName: 

105 if not instance.styleName: 

106 raise VarLibValidationError( 

107 f"Instance at location '{coordinates}' must have a default English " 

108 "style name ('stylename' attribute on the instance element or a " 

109 "stylename element with an 'xml:lang=\"en\"' attribute)." 

110 ) 

111 localisedStyleName = dict(instance.localisedStyleName) 

112 localisedStyleName["en"] = tostr(instance.styleName) 

113 else: 

114 localisedStyleName = instance.localisedStyleName 

115 

116 psname = instance.postScriptFontName 

117 

118 inst = NamedInstance() 

119 inst.subfamilyNameID = nameTable.addMultilingualName(localisedStyleName) 

120 if psname is not None: 

121 psname = tostr(psname) 

122 inst.postscriptNameID = nameTable.addName(psname) 

123 inst.coordinates = { 

124 axes[k].tag: axes[k].map_backward(v) for k, v in coordinates.items() 

125 } 

126 # inst.coordinates = {axes[k].tag:v for k,v in coordinates.items()} 

127 fvar.instances.append(inst) 

128 

129 assert "fvar" not in font 

130 font["fvar"] = fvar 

131 

132 return fvar 

133 

134 

135def _add_avar(font, axes, mappings, axisTags): 

136 """ 

137 Add 'avar' table to font. 

138 

139 axes is an ordered dictionary of AxisDescriptor objects. 

140 """ 

141 

142 assert axes 

143 assert isinstance(axes, OrderedDict) 

144 

145 log.info("Generating avar") 

146 

147 avar = newTable("avar") 

148 

149 interesting = False 

150 vals_triples = {} 

151 for axis in axes.values(): 

152 # Currently, some rasterizers require that the default value maps 

153 # (-1 to -1, 0 to 0, and 1 to 1) be present for all the segment 

154 # maps, even when the default normalization mapping for the axis 

155 # was not modified. 

156 # https://github.com/googlei18n/fontmake/issues/295 

157 # https://github.com/fonttools/fonttools/issues/1011 

158 # TODO(anthrotype) revert this (and 19c4b37) when issue is fixed 

159 curve = avar.segments[axis.tag] = {-1.0: -1.0, 0.0: 0.0, 1.0: 1.0} 

160 

161 keys_triple = (axis.minimum, axis.default, axis.maximum) 

162 vals_triple = tuple(axis.map_forward(v) for v in keys_triple) 

163 vals_triples[axis.tag] = vals_triple 

164 

165 if not axis.map: 

166 continue 

167 

168 items = sorted(axis.map) 

169 keys = [item[0] for item in items] 

170 vals = [item[1] for item in items] 

171 

172 # Current avar requirements. We don't have to enforce 

173 # these on the designer and can deduce some ourselves, 

174 # but for now just enforce them. 

175 if axis.minimum != min(keys): 

176 raise VarLibValidationError( 

177 f"Axis '{axis.name}': there must be a mapping for the axis minimum " 

178 f"value {axis.minimum} and it must be the lowest input mapping value." 

179 ) 

180 if axis.maximum != max(keys): 

181 raise VarLibValidationError( 

182 f"Axis '{axis.name}': there must be a mapping for the axis maximum " 

183 f"value {axis.maximum} and it must be the highest input mapping value." 

184 ) 

185 if axis.default not in keys: 

186 raise VarLibValidationError( 

187 f"Axis '{axis.name}': there must be a mapping for the axis default " 

188 f"value {axis.default}." 

189 ) 

190 # No duplicate input values (output values can be >= their preceeding value). 

191 if len(set(keys)) != len(keys): 

192 raise VarLibValidationError( 

193 f"Axis '{axis.name}': All axis mapping input='...' values must be " 

194 "unique, but we found duplicates." 

195 ) 

196 # Ascending values 

197 if sorted(vals) != vals: 

198 raise VarLibValidationError( 

199 f"Axis '{axis.name}': mapping output values must be in ascending order." 

200 ) 

201 

202 keys = [models.normalizeValue(v, keys_triple) for v in keys] 

203 vals = [models.normalizeValue(v, vals_triple) for v in vals] 

204 

205 if all(k == v for k, v in zip(keys, vals)): 

206 continue 

207 interesting = True 

208 

209 curve.update(zip(keys, vals)) 

210 

211 assert 0.0 in curve and curve[0.0] == 0.0 

212 assert -1.0 not in curve or curve[-1.0] == -1.0 

213 assert +1.0 not in curve or curve[+1.0] == +1.0 

214 # curve.update({-1.0: -1.0, 0.0: 0.0, 1.0: 1.0}) 

215 

216 if mappings: 

217 interesting = True 

218 

219 hiddenAxes = [axis for axis in axes.values() if axis.hidden] 

220 

221 inputLocations = [ 

222 { 

223 axes[name].tag: models.normalizeValue(v, vals_triples[axes[name].tag]) 

224 for name, v in mapping.inputLocation.items() 

225 } 

226 for mapping in mappings 

227 ] 

228 outputLocations = [ 

229 { 

230 axes[name].tag: models.normalizeValue(v, vals_triples[axes[name].tag]) 

231 for name, v in mapping.outputLocation.items() 

232 } 

233 for mapping in mappings 

234 ] 

235 assert len(inputLocations) == len(outputLocations) 

236 

237 # If base-master is missing, insert it at zero location. 

238 if not any(all(v == 0 for k, v in loc.items()) for loc in inputLocations): 

239 inputLocations.insert(0, {}) 

240 outputLocations.insert(0, {}) 

241 

242 model = models.VariationModel(inputLocations, axisTags) 

243 storeBuilder = varStore.OnlineVarStoreBuilder(axisTags) 

244 storeBuilder.setModel(model) 

245 varIdxes = {} 

246 for tag in axisTags: 

247 masterValues = [] 

248 for vo, vi in zip(outputLocations, inputLocations): 

249 if tag not in vo: 

250 masterValues.append(0) 

251 continue 

252 v = vo[tag] - vi.get(tag, 0) 

253 masterValues.append(fl2fi(v, 14)) 

254 varIdxes[tag] = storeBuilder.storeMasters(masterValues)[1] 

255 

256 store = storeBuilder.finish() 

257 optimized = store.optimize() 

258 varIdxes = {axis: optimized[value] for axis, value in varIdxes.items()} 

259 

260 varIdxMap = builder.buildDeltaSetIndexMap(varIdxes[t] for t in axisTags) 

261 

262 avar.majorVersion = 2 

263 avar.table = ot.avar() 

264 avar.table.VarIdxMap = varIdxMap 

265 avar.table.VarStore = store 

266 

267 assert "avar" not in font 

268 if not interesting: 

269 log.info("No need for avar") 

270 avar = None 

271 else: 

272 font["avar"] = avar 

273 

274 return avar 

275 

276 

277def _add_stat(font): 

278 # Note: this function only gets called by old code that calls `build()` 

279 # directly. Newer code that wants to benefit from STAT data from the 

280 # designspace should call `build_many()` 

281 

282 if "STAT" in font: 

283 return 

284 

285 from ..otlLib.builder import buildStatTable 

286 

287 fvarTable = font["fvar"] 

288 axes = [dict(tag=a.axisTag, name=a.axisNameID) for a in fvarTable.axes] 

289 buildStatTable(font, axes) 

290 

291 

292_MasterData = namedtuple("_MasterData", ["glyf", "hMetrics", "vMetrics"]) 

293 

294 

295def _add_gvar(font, masterModel, master_ttfs, tolerance=0.5, optimize=True): 

296 if tolerance < 0: 

297 raise ValueError("`tolerance` must be a positive number.") 

298 

299 log.info("Generating gvar") 

300 assert "gvar" not in font 

301 gvar = font["gvar"] = newTable("gvar") 

302 glyf = font["glyf"] 

303 defaultMasterIndex = masterModel.reverseMapping[0] 

304 

305 master_datas = [ 

306 _MasterData( 

307 m["glyf"], m["hmtx"].metrics, getattr(m.get("vmtx"), "metrics", None) 

308 ) 

309 for m in master_ttfs 

310 ] 

311 

312 for glyph in font.getGlyphOrder(): 

313 log.debug("building gvar for glyph '%s'", glyph) 

314 isComposite = glyf[glyph].isComposite() 

315 

316 allData = [ 

317 m.glyf._getCoordinatesAndControls(glyph, m.hMetrics, m.vMetrics) 

318 for m in master_datas 

319 ] 

320 

321 if allData[defaultMasterIndex][1].numberOfContours != 0: 

322 # If the default master is not empty, interpret empty non-default masters 

323 # as missing glyphs from a sparse master 

324 allData = [ 

325 d if d is not None and d[1].numberOfContours != 0 else None 

326 for d in allData 

327 ] 

328 

329 model, allData = masterModel.getSubModel(allData) 

330 

331 allCoords = [d[0] for d in allData] 

332 allControls = [d[1] for d in allData] 

333 control = allControls[0] 

334 if not models.allEqual(allControls): 

335 log.warning("glyph %s has incompatible masters; skipping" % glyph) 

336 continue 

337 del allControls 

338 

339 # Update gvar 

340 gvar.variations[glyph] = [] 

341 deltas = model.getDeltas( 

342 allCoords, round=partial(GlyphCoordinates.__round__, round=round) 

343 ) 

344 supports = model.supports 

345 assert len(deltas) == len(supports) 

346 

347 # Prepare for IUP optimization 

348 origCoords = deltas[0] 

349 endPts = control.endPts 

350 

351 for i, (delta, support) in enumerate(zip(deltas[1:], supports[1:])): 

352 if all(v == 0 for v in delta.array) and not isComposite: 

353 continue 

354 var = TupleVariation(support, delta) 

355 if optimize: 

356 delta_opt = iup_delta_optimize( 

357 delta, origCoords, endPts, tolerance=tolerance 

358 ) 

359 

360 if None in delta_opt: 

361 """In composite glyphs, there should be one 0 entry 

362 to make sure the gvar entry is written to the font. 

363 

364 This is to work around an issue with macOS 10.14 and can be 

365 removed once the behaviour of macOS is changed. 

366 

367 https://github.com/fonttools/fonttools/issues/1381 

368 """ 

369 if all(d is None for d in delta_opt): 

370 delta_opt = [(0, 0)] + [None] * (len(delta_opt) - 1) 

371 # Use "optimized" version only if smaller... 

372 var_opt = TupleVariation(support, delta_opt) 

373 

374 axis_tags = sorted( 

375 support.keys() 

376 ) # Shouldn't matter that this is different from fvar...? 

377 tupleData, auxData = var.compile(axis_tags) 

378 unoptimized_len = len(tupleData) + len(auxData) 

379 tupleData, auxData = var_opt.compile(axis_tags) 

380 optimized_len = len(tupleData) + len(auxData) 

381 

382 if optimized_len < unoptimized_len: 

383 var = var_opt 

384 

385 gvar.variations[glyph].append(var) 

386 

387 

388def _remove_TTHinting(font): 

389 for tag in ("cvar", "cvt ", "fpgm", "prep"): 

390 if tag in font: 

391 del font[tag] 

392 maxp = font["maxp"] 

393 for attr in ( 

394 "maxTwilightPoints", 

395 "maxStorage", 

396 "maxFunctionDefs", 

397 "maxInstructionDefs", 

398 "maxStackElements", 

399 "maxSizeOfInstructions", 

400 ): 

401 setattr(maxp, attr, 0) 

402 maxp.maxZones = 1 

403 font["glyf"].removeHinting() 

404 # TODO: Modify gasp table to deactivate gridfitting for all ranges? 

405 

406 

407def _merge_TTHinting(font, masterModel, master_ttfs): 

408 log.info("Merging TT hinting") 

409 assert "cvar" not in font 

410 

411 # Check that the existing hinting is compatible 

412 

413 # fpgm and prep table 

414 

415 for tag in ("fpgm", "prep"): 

416 all_pgms = [m[tag].program for m in master_ttfs if tag in m] 

417 if not all_pgms: 

418 continue 

419 font_pgm = getattr(font.get(tag), "program", None) 

420 if any(pgm != font_pgm for pgm in all_pgms): 

421 log.warning( 

422 "Masters have incompatible %s tables, hinting is discarded." % tag 

423 ) 

424 _remove_TTHinting(font) 

425 return 

426 

427 # glyf table 

428 

429 font_glyf = font["glyf"] 

430 master_glyfs = [m["glyf"] for m in master_ttfs] 

431 for name, glyph in font_glyf.glyphs.items(): 

432 all_pgms = [getattr(glyf.get(name), "program", None) for glyf in master_glyfs] 

433 if not any(all_pgms): 

434 continue 

435 glyph.expand(font_glyf) 

436 font_pgm = getattr(glyph, "program", None) 

437 if any(pgm != font_pgm for pgm in all_pgms if pgm): 

438 log.warning( 

439 "Masters have incompatible glyph programs in glyph '%s', hinting is discarded." 

440 % name 

441 ) 

442 # TODO Only drop hinting from this glyph. 

443 _remove_TTHinting(font) 

444 return 

445 

446 # cvt table 

447 

448 all_cvs = [Vector(m["cvt "].values) if "cvt " in m else None for m in master_ttfs] 

449 

450 nonNone_cvs = models.nonNone(all_cvs) 

451 if not nonNone_cvs: 

452 # There is no cvt table to make a cvar table from, we're done here. 

453 return 

454 

455 if not models.allEqual(len(c) for c in nonNone_cvs): 

456 log.warning("Masters have incompatible cvt tables, hinting is discarded.") 

457 _remove_TTHinting(font) 

458 return 

459 

460 variations = [] 

461 deltas, supports = masterModel.getDeltasAndSupports( 

462 all_cvs, round=round 

463 ) # builtin round calls into Vector.__round__, which uses builtin round as we like 

464 for i, (delta, support) in enumerate(zip(deltas[1:], supports[1:])): 

465 if all(v == 0 for v in delta): 

466 continue 

467 var = TupleVariation(support, delta) 

468 variations.append(var) 

469 

470 # We can build the cvar table now. 

471 if variations: 

472 cvar = font["cvar"] = newTable("cvar") 

473 cvar.version = 1 

474 cvar.variations = variations 

475 

476 

477_MetricsFields = namedtuple( 

478 "_MetricsFields", 

479 ["tableTag", "metricsTag", "sb1", "sb2", "advMapping", "vOrigMapping"], 

480) 

481 

482HVAR_FIELDS = _MetricsFields( 

483 tableTag="HVAR", 

484 metricsTag="hmtx", 

485 sb1="LsbMap", 

486 sb2="RsbMap", 

487 advMapping="AdvWidthMap", 

488 vOrigMapping=None, 

489) 

490 

491VVAR_FIELDS = _MetricsFields( 

492 tableTag="VVAR", 

493 metricsTag="vmtx", 

494 sb1="TsbMap", 

495 sb2="BsbMap", 

496 advMapping="AdvHeightMap", 

497 vOrigMapping="VOrgMap", 

498) 

499 

500 

501def _add_HVAR(font, masterModel, master_ttfs, axisTags): 

502 _add_VHVAR(font, masterModel, master_ttfs, axisTags, HVAR_FIELDS) 

503 

504 

505def _add_VVAR(font, masterModel, master_ttfs, axisTags): 

506 _add_VHVAR(font, masterModel, master_ttfs, axisTags, VVAR_FIELDS) 

507 

508 

509def _add_VHVAR(font, masterModel, master_ttfs, axisTags, tableFields): 

510 tableTag = tableFields.tableTag 

511 assert tableTag not in font 

512 log.info("Generating " + tableTag) 

513 VHVAR = newTable(tableTag) 

514 tableClass = getattr(ot, tableTag) 

515 vhvar = VHVAR.table = tableClass() 

516 vhvar.Version = 0x00010000 

517 

518 glyphOrder = font.getGlyphOrder() 

519 

520 # Build list of source font advance widths for each glyph 

521 metricsTag = tableFields.metricsTag 

522 advMetricses = [m[metricsTag].metrics for m in master_ttfs] 

523 

524 # Build list of source font vertical origin coords for each glyph 

525 if tableTag == "VVAR" and "VORG" in master_ttfs[0]: 

526 vOrigMetricses = [m["VORG"].VOriginRecords for m in master_ttfs] 

527 defaultYOrigs = [m["VORG"].defaultVertOriginY for m in master_ttfs] 

528 vOrigMetricses = list(zip(vOrigMetricses, defaultYOrigs)) 

529 else: 

530 vOrigMetricses = None 

531 

532 metricsStore, advanceMapping, vOrigMapping = _get_advance_metrics( 

533 font, 

534 masterModel, 

535 master_ttfs, 

536 axisTags, 

537 glyphOrder, 

538 advMetricses, 

539 vOrigMetricses, 

540 ) 

541 

542 vhvar.VarStore = metricsStore 

543 if advanceMapping is None: 

544 setattr(vhvar, tableFields.advMapping, None) 

545 else: 

546 setattr(vhvar, tableFields.advMapping, advanceMapping) 

547 if vOrigMapping is not None: 

548 setattr(vhvar, tableFields.vOrigMapping, vOrigMapping) 

549 setattr(vhvar, tableFields.sb1, None) 

550 setattr(vhvar, tableFields.sb2, None) 

551 

552 font[tableTag] = VHVAR 

553 return 

554 

555 

556def _get_advance_metrics( 

557 font, 

558 masterModel, 

559 master_ttfs, 

560 axisTags, 

561 glyphOrder, 

562 advMetricses, 

563 vOrigMetricses=None, 

564): 

565 vhAdvanceDeltasAndSupports = {} 

566 vOrigDeltasAndSupports = {} 

567 # HACK: we treat width 65535 as a sentinel value to signal that a glyph 

568 # from a non-default master should not participate in computing {H,V}VAR, 

569 # as if it were missing. Allows to variate other glyph-related data independently 

570 # from glyph metrics 

571 sparse_advance = 0xFFFF 

572 for glyph in glyphOrder: 

573 vhAdvances = [ 

574 metrics[glyph][0] 

575 if glyph in metrics and metrics[glyph][0] != sparse_advance 

576 else None 

577 for metrics in advMetricses 

578 ] 

579 vhAdvanceDeltasAndSupports[glyph] = masterModel.getDeltasAndSupports( 

580 vhAdvances, round=round 

581 ) 

582 

583 singleModel = models.allEqual(id(v[1]) for v in vhAdvanceDeltasAndSupports.values()) 

584 

585 if vOrigMetricses: 

586 singleModel = False 

587 for glyph in glyphOrder: 

588 # We need to supply a vOrigs tuple with non-None default values 

589 # for each glyph. vOrigMetricses contains values only for those 

590 # glyphs which have a non-default vOrig. 

591 vOrigs = [ 

592 metrics[glyph] if glyph in metrics else defaultVOrig 

593 for metrics, defaultVOrig in vOrigMetricses 

594 ] 

595 vOrigDeltasAndSupports[glyph] = masterModel.getDeltasAndSupports( 

596 vOrigs, round=round 

597 ) 

598 

599 directStore = None 

600 if singleModel: 

601 # Build direct mapping 

602 supports = next(iter(vhAdvanceDeltasAndSupports.values()))[1][1:] 

603 varTupleList = builder.buildVarRegionList(supports, axisTags) 

604 varTupleIndexes = list(range(len(supports))) 

605 varData = builder.buildVarData(varTupleIndexes, [], optimize=False) 

606 for glyphName in glyphOrder: 

607 varData.addItem(vhAdvanceDeltasAndSupports[glyphName][0], round=noRound) 

608 varData.optimize() 

609 directStore = builder.buildVarStore(varTupleList, [varData]) 

610 

611 # Build optimized indirect mapping 

612 storeBuilder = varStore.OnlineVarStoreBuilder(axisTags) 

613 advMapping = {} 

614 for glyphName in glyphOrder: 

615 deltas, supports = vhAdvanceDeltasAndSupports[glyphName] 

616 storeBuilder.setSupports(supports) 

617 advMapping[glyphName] = storeBuilder.storeDeltas(deltas, round=noRound) 

618 

619 if vOrigMetricses: 

620 vOrigMap = {} 

621 for glyphName in glyphOrder: 

622 deltas, supports = vOrigDeltasAndSupports[glyphName] 

623 storeBuilder.setSupports(supports) 

624 vOrigMap[glyphName] = storeBuilder.storeDeltas(deltas, round=noRound) 

625 

626 indirectStore = storeBuilder.finish() 

627 mapping2 = indirectStore.optimize(use_NO_VARIATION_INDEX=False) 

628 advMapping = [mapping2[advMapping[g]] for g in glyphOrder] 

629 advanceMapping = builder.buildVarIdxMap(advMapping, glyphOrder) 

630 

631 if vOrigMetricses: 

632 vOrigMap = [mapping2[vOrigMap[g]] for g in glyphOrder] 

633 

634 useDirect = False 

635 vOrigMapping = None 

636 if directStore: 

637 # Compile both, see which is more compact 

638 

639 writer = OTTableWriter() 

640 directStore.compile(writer, font) 

641 directSize = len(writer.getAllData()) 

642 

643 writer = OTTableWriter() 

644 indirectStore.compile(writer, font) 

645 advanceMapping.compile(writer, font) 

646 indirectSize = len(writer.getAllData()) 

647 

648 useDirect = directSize < indirectSize 

649 

650 if useDirect: 

651 metricsStore = directStore 

652 advanceMapping = None 

653 else: 

654 metricsStore = indirectStore 

655 if vOrigMetricses: 

656 vOrigMapping = builder.buildVarIdxMap(vOrigMap, glyphOrder) 

657 

658 return metricsStore, advanceMapping, vOrigMapping 

659 

660 

661def _add_MVAR(font, masterModel, master_ttfs, axisTags): 

662 log.info("Generating MVAR") 

663 

664 store_builder = varStore.OnlineVarStoreBuilder(axisTags) 

665 

666 records = [] 

667 lastTableTag = None 

668 fontTable = None 

669 tables = None 

670 # HACK: we need to special-case post.underlineThickness and .underlinePosition 

671 # and unilaterally/arbitrarily define a sentinel value to distinguish the case 

672 # when a post table is present in a given master simply because that's where 

673 # the glyph names in TrueType must be stored, but the underline values are not 

674 # meant to be used for building MVAR's deltas. The value of -0x8000 (-36768) 

675 # the minimum FWord (int16) value, was chosen for its unlikelyhood to appear 

676 # in real-world underline position/thickness values. 

677 specialTags = {"unds": -0x8000, "undo": -0x8000} 

678 

679 for tag, (tableTag, itemName) in sorted(MVAR_ENTRIES.items(), key=lambda kv: kv[1]): 

680 # For each tag, fetch the associated table from all fonts (or not when we are 

681 # still looking at a tag from the same tables) and set up the variation model 

682 # for them. 

683 if tableTag != lastTableTag: 

684 tables = fontTable = None 

685 if tableTag in font: 

686 fontTable = font[tableTag] 

687 tables = [] 

688 for master in master_ttfs: 

689 if tableTag not in master or ( 

690 tag in specialTags 

691 and getattr(master[tableTag], itemName) == specialTags[tag] 

692 ): 

693 tables.append(None) 

694 else: 

695 tables.append(master[tableTag]) 

696 model, tables = masterModel.getSubModel(tables) 

697 store_builder.setModel(model) 

698 lastTableTag = tableTag 

699 

700 if tables is None: # Tag not applicable to the master font. 

701 continue 

702 

703 # TODO support gasp entries 

704 

705 master_values = [getattr(table, itemName) for table in tables] 

706 if models.allEqual(master_values): 

707 base, varIdx = master_values[0], None 

708 else: 

709 base, varIdx = store_builder.storeMasters(master_values) 

710 setattr(fontTable, itemName, base) 

711 

712 if varIdx is None: 

713 continue 

714 log.info(" %s: %s.%s %s", tag, tableTag, itemName, master_values) 

715 rec = ot.MetricsValueRecord() 

716 rec.ValueTag = tag 

717 rec.VarIdx = varIdx 

718 records.append(rec) 

719 

720 assert "MVAR" not in font 

721 if records: 

722 store = store_builder.finish() 

723 # Optimize 

724 mapping = store.optimize() 

725 for rec in records: 

726 rec.VarIdx = mapping[rec.VarIdx] 

727 

728 MVAR = font["MVAR"] = newTable("MVAR") 

729 mvar = MVAR.table = ot.MVAR() 

730 mvar.Version = 0x00010000 

731 mvar.Reserved = 0 

732 mvar.VarStore = store 

733 # XXX these should not be hard-coded but computed automatically 

734 mvar.ValueRecordSize = 8 

735 mvar.ValueRecordCount = len(records) 

736 mvar.ValueRecord = sorted(records, key=lambda r: r.ValueTag) 

737 

738 

739def _add_BASE(font, masterModel, master_ttfs, axisTags): 

740 log.info("Generating BASE") 

741 

742 merger = VariationMerger(masterModel, axisTags, font) 

743 merger.mergeTables(font, master_ttfs, ["BASE"]) 

744 store = merger.store_builder.finish() 

745 

746 if not store: 

747 return 

748 base = font["BASE"].table 

749 assert base.Version == 0x00010000 

750 base.Version = 0x00010001 

751 base.VarStore = store 

752 

753 

754def _merge_OTL(font, model, master_fonts, axisTags): 

755 log.info("Merging OpenType Layout tables") 

756 merger = VariationMerger(model, axisTags, font) 

757 

758 merger.mergeTables(font, master_fonts, ["GSUB", "GDEF", "GPOS"]) 

759 store = merger.store_builder.finish() 

760 if not store: 

761 return 

762 try: 

763 GDEF = font["GDEF"].table 

764 assert GDEF.Version <= 0x00010002 

765 except KeyError: 

766 font["GDEF"] = newTable("GDEF") 

767 GDEFTable = font["GDEF"] = newTable("GDEF") 

768 GDEF = GDEFTable.table = ot.GDEF() 

769 GDEF.GlyphClassDef = None 

770 GDEF.AttachList = None 

771 GDEF.LigCaretList = None 

772 GDEF.MarkAttachClassDef = None 

773 GDEF.MarkGlyphSetsDef = None 

774 

775 GDEF.Version = 0x00010003 

776 GDEF.VarStore = store 

777 

778 # Optimize 

779 varidx_map = store.optimize() 

780 GDEF.remap_device_varidxes(varidx_map) 

781 if "GPOS" in font: 

782 font["GPOS"].table.remap_device_varidxes(varidx_map) 

783 

784 

785def _add_GSUB_feature_variations( 

786 font, axes, internal_axis_supports, rules, featureTags 

787): 

788 def normalize(name, value): 

789 return models.normalizeLocation({name: value}, internal_axis_supports)[name] 

790 

791 log.info("Generating GSUB FeatureVariations") 

792 

793 axis_tags = {name: axis.tag for name, axis in axes.items()} 

794 

795 conditional_subs = [] 

796 for rule in rules: 

797 region = [] 

798 for conditions in rule.conditionSets: 

799 space = {} 

800 for condition in conditions: 

801 axis_name = condition["name"] 

802 if condition["minimum"] is not None: 

803 minimum = normalize(axis_name, condition["minimum"]) 

804 else: 

805 minimum = -1.0 

806 if condition["maximum"] is not None: 

807 maximum = normalize(axis_name, condition["maximum"]) 

808 else: 

809 maximum = 1.0 

810 tag = axis_tags[axis_name] 

811 space[tag] = (minimum, maximum) 

812 region.append(space) 

813 

814 subs = {k: v for k, v in rule.subs} 

815 

816 conditional_subs.append((region, subs)) 

817 

818 addFeatureVariations(font, conditional_subs, featureTags) 

819 

820 

821_DesignSpaceData = namedtuple( 

822 "_DesignSpaceData", 

823 [ 

824 "axes", 

825 "axisMappings", 

826 "internal_axis_supports", 

827 "base_idx", 

828 "normalized_master_locs", 

829 "masters", 

830 "instances", 

831 "rules", 

832 "rulesProcessingLast", 

833 "lib", 

834 ], 

835) 

836 

837 

838def _add_CFF2(varFont, model, master_fonts): 

839 from .cff import merge_region_fonts 

840 

841 glyphOrder = varFont.getGlyphOrder() 

842 if "CFF2" not in varFont: 

843 from .cff import convertCFFtoCFF2 

844 

845 convertCFFtoCFF2(varFont) 

846 ordered_fonts_list = model.reorderMasters(master_fonts, model.reverseMapping) 

847 # re-ordering the master list simplifies building the CFF2 data item lists. 

848 merge_region_fonts(varFont, model, ordered_fonts_list, glyphOrder) 

849 

850 

851def _add_COLR(font, model, master_fonts, axisTags, colr_layer_reuse=True): 

852 merger = COLRVariationMerger( 

853 model, axisTags, font, allowLayerReuse=colr_layer_reuse 

854 ) 

855 merger.mergeTables(font, master_fonts) 

856 store = merger.store_builder.finish() 

857 

858 colr = font["COLR"].table 

859 if store: 

860 mapping = store.optimize() 

861 colr.VarStore = store 

862 varIdxes = [mapping[v] for v in merger.varIdxes] 

863 colr.VarIndexMap = builder.buildDeltaSetIndexMap(varIdxes) 

864 

865 

866def load_designspace(designspace, log_enabled=True): 

867 # TODO: remove this and always assume 'designspace' is a DesignSpaceDocument, 

868 # never a file path, as that's already handled by caller 

869 if hasattr(designspace, "sources"): # Assume a DesignspaceDocument 

870 ds = designspace 

871 else: # Assume a file path 

872 ds = DesignSpaceDocument.fromfile(designspace) 

873 

874 masters = ds.sources 

875 if not masters: 

876 raise VarLibValidationError("Designspace must have at least one source.") 

877 instances = ds.instances 

878 

879 # TODO: Use fontTools.designspaceLib.tagForAxisName instead. 

880 standard_axis_map = OrderedDict( 

881 [ 

882 ("weight", ("wght", {"en": "Weight"})), 

883 ("width", ("wdth", {"en": "Width"})), 

884 ("slant", ("slnt", {"en": "Slant"})), 

885 ("optical", ("opsz", {"en": "Optical Size"})), 

886 ("italic", ("ital", {"en": "Italic"})), 

887 ] 

888 ) 

889 

890 # Setup axes 

891 if not ds.axes: 

892 raise VarLibValidationError(f"Designspace must have at least one axis.") 

893 

894 axes = OrderedDict() 

895 for axis_index, axis in enumerate(ds.axes): 

896 axis_name = axis.name 

897 if not axis_name: 

898 if not axis.tag: 

899 raise VarLibValidationError(f"Axis at index {axis_index} needs a tag.") 

900 axis_name = axis.name = axis.tag 

901 

902 if axis_name in standard_axis_map: 

903 if axis.tag is None: 

904 axis.tag = standard_axis_map[axis_name][0] 

905 if not axis.labelNames: 

906 axis.labelNames.update(standard_axis_map[axis_name][1]) 

907 else: 

908 if not axis.tag: 

909 raise VarLibValidationError(f"Axis at index {axis_index} needs a tag.") 

910 if not axis.labelNames: 

911 axis.labelNames["en"] = tostr(axis_name) 

912 

913 axes[axis_name] = axis 

914 if log_enabled: 

915 log.info("Axes:\n%s", pformat([axis.asdict() for axis in axes.values()])) 

916 

917 axisMappings = ds.axisMappings 

918 if axisMappings and log_enabled: 

919 log.info("Mappings:\n%s", pformat(axisMappings)) 

920 

921 # Check all master and instance locations are valid and fill in defaults 

922 for obj in masters + instances: 

923 obj_name = obj.name or obj.styleName or "" 

924 loc = obj.getFullDesignLocation(ds) 

925 obj.designLocation = loc 

926 if loc is None: 

927 raise VarLibValidationError( 

928 f"Source or instance '{obj_name}' has no location." 

929 ) 

930 for axis_name in loc.keys(): 

931 if axis_name not in axes: 

932 raise VarLibValidationError( 

933 f"Location axis '{axis_name}' unknown for '{obj_name}'." 

934 ) 

935 for axis_name, axis in axes.items(): 

936 v = axis.map_backward(loc[axis_name]) 

937 if not (axis.minimum <= v <= axis.maximum): 

938 raise VarLibValidationError( 

939 f"Source or instance '{obj_name}' has out-of-range location " 

940 f"for axis '{axis_name}': is mapped to {v} but must be in " 

941 f"mapped range [{axis.minimum}..{axis.maximum}] (NOTE: all " 

942 "values are in user-space)." 

943 ) 

944 

945 # Normalize master locations 

946 

947 internal_master_locs = [o.getFullDesignLocation(ds) for o in masters] 

948 if log_enabled: 

949 log.info("Internal master locations:\n%s", pformat(internal_master_locs)) 

950 

951 # TODO This mapping should ideally be moved closer to logic in _add_fvar/avar 

952 internal_axis_supports = {} 

953 for axis in axes.values(): 

954 triple = (axis.minimum, axis.default, axis.maximum) 

955 internal_axis_supports[axis.name] = [axis.map_forward(v) for v in triple] 

956 if log_enabled: 

957 log.info("Internal axis supports:\n%s", pformat(internal_axis_supports)) 

958 

959 normalized_master_locs = [ 

960 models.normalizeLocation(m, internal_axis_supports) 

961 for m in internal_master_locs 

962 ] 

963 if log_enabled: 

964 log.info("Normalized master locations:\n%s", pformat(normalized_master_locs)) 

965 

966 # Find base master 

967 base_idx = None 

968 for i, m in enumerate(normalized_master_locs): 

969 if all(v == 0 for v in m.values()): 

970 if base_idx is not None: 

971 raise VarLibValidationError( 

972 "More than one base master found in Designspace." 

973 ) 

974 base_idx = i 

975 if base_idx is None: 

976 raise VarLibValidationError( 

977 "Base master not found; no master at default location?" 

978 ) 

979 if log_enabled: 

980 log.info("Index of base master: %s", base_idx) 

981 

982 return _DesignSpaceData( 

983 axes, 

984 axisMappings, 

985 internal_axis_supports, 

986 base_idx, 

987 normalized_master_locs, 

988 masters, 

989 instances, 

990 ds.rules, 

991 ds.rulesProcessingLast, 

992 ds.lib, 

993 ) 

994 

995 

996# https://docs.microsoft.com/en-us/typography/opentype/spec/os2#uswidthclass 

997WDTH_VALUE_TO_OS2_WIDTH_CLASS = { 

998 50: 1, 

999 62.5: 2, 

1000 75: 3, 

1001 87.5: 4, 

1002 100: 5, 

1003 112.5: 6, 

1004 125: 7, 

1005 150: 8, 

1006 200: 9, 

1007} 

1008 

1009 

1010def set_default_weight_width_slant(font, location): 

1011 if "OS/2" in font: 

1012 if "wght" in location: 

1013 weight_class = otRound(max(1, min(location["wght"], 1000))) 

1014 if font["OS/2"].usWeightClass != weight_class: 

1015 log.info("Setting OS/2.usWeightClass = %s", weight_class) 

1016 font["OS/2"].usWeightClass = weight_class 

1017 

1018 if "wdth" in location: 

1019 # map 'wdth' axis (50..200) to OS/2.usWidthClass (1..9), rounding to closest 

1020 widthValue = min(max(location["wdth"], 50), 200) 

1021 widthClass = otRound( 

1022 models.piecewiseLinearMap(widthValue, WDTH_VALUE_TO_OS2_WIDTH_CLASS) 

1023 ) 

1024 if font["OS/2"].usWidthClass != widthClass: 

1025 log.info("Setting OS/2.usWidthClass = %s", widthClass) 

1026 font["OS/2"].usWidthClass = widthClass 

1027 

1028 if "slnt" in location and "post" in font: 

1029 italicAngle = max(-90, min(location["slnt"], 90)) 

1030 if font["post"].italicAngle != italicAngle: 

1031 log.info("Setting post.italicAngle = %s", italicAngle) 

1032 font["post"].italicAngle = italicAngle 

1033 

1034 

1035def drop_implied_oncurve_points(*masters: TTFont) -> int: 

1036 """Drop impliable on-curve points from all the simple glyphs in masters. 

1037 

1038 In TrueType glyf outlines, on-curve points can be implied when they are located 

1039 exactly at the midpoint of the line connecting two consecutive off-curve points. 

1040 

1041 The input masters' glyf tables are assumed to contain same-named glyphs that are 

1042 interpolatable. Oncurve points are only dropped if they can be implied for all 

1043 the masters. The fonts are modified in-place. 

1044 

1045 Args: 

1046 masters: The TTFont(s) to modify 

1047 

1048 Returns: 

1049 The total number of points that were dropped if any. 

1050 

1051 Reference: 

1052 https://developer.apple.com/fonts/TrueType-Reference-Manual/RM01/Chap1.html 

1053 """ 

1054 

1055 count = 0 

1056 glyph_masters = defaultdict(list) 

1057 # multiple DS source may point to the same TTFont object and we want to 

1058 # avoid processing the same glyph twice as they are modified in-place 

1059 for font in {id(m): m for m in masters}.values(): 

1060 glyf = font["glyf"] 

1061 for glyphName in glyf.keys(): 

1062 glyph_masters[glyphName].append(glyf[glyphName]) 

1063 count = 0 

1064 for glyphName, glyphs in glyph_masters.items(): 

1065 try: 

1066 dropped = dropImpliedOnCurvePoints(*glyphs) 

1067 except ValueError as e: 

1068 # we don't fail for incompatible glyphs in _add_gvar so we shouldn't here 

1069 log.warning("Failed to drop implied oncurves for %r: %s", glyphName, e) 

1070 else: 

1071 count += len(dropped) 

1072 return count 

1073 

1074 

1075def build_many( 

1076 designspace: DesignSpaceDocument, 

1077 master_finder=lambda s: s, 

1078 exclude=[], 

1079 optimize=True, 

1080 skip_vf=lambda vf_name: False, 

1081 colr_layer_reuse=True, 

1082 drop_implied_oncurves=False, 

1083): 

1084 """ 

1085 Build variable fonts from a designspace file, version 5 which can define 

1086 several VFs, or version 4 which has implicitly one VF covering the whole doc. 

1087 

1088 If master_finder is set, it should be a callable that takes master 

1089 filename as found in designspace file and map it to master font 

1090 binary as to be opened (eg. .ttf or .otf). 

1091 

1092 skip_vf can be used to skip building some of the variable fonts defined in 

1093 the input designspace. It's a predicate that takes as argument the name 

1094 of the variable font and returns `bool`. 

1095 

1096 Always returns a Dict[str, TTFont] keyed by VariableFontDescriptor.name 

1097 """ 

1098 res = {} 

1099 # varLib.build (used further below) by default only builds an incomplete 'STAT' 

1100 # with an empty AxisValueArray--unless the VF inherited 'STAT' from its base master. 

1101 # Designspace version 5 can also be used to define 'STAT' labels or customize 

1102 # axes ordering, etc. To avoid overwriting a pre-existing 'STAT' or redoing the 

1103 # same work twice, here we check if designspace contains any 'STAT' info before 

1104 # proceeding to call buildVFStatTable for each VF. 

1105 # https://github.com/fonttools/fonttools/pull/3024 

1106 # https://github.com/fonttools/fonttools/issues/3045 

1107 doBuildStatFromDSv5 = ( 

1108 "STAT" not in exclude 

1109 and designspace.formatTuple >= (5, 0) 

1110 and ( 

1111 any(a.axisLabels or a.axisOrdering is not None for a in designspace.axes) 

1112 or designspace.locationLabels 

1113 ) 

1114 ) 

1115 for _location, subDoc in splitInterpolable(designspace): 

1116 for name, vfDoc in splitVariableFonts(subDoc): 

1117 if skip_vf(name): 

1118 log.debug(f"Skipping variable TTF font: {name}") 

1119 continue 

1120 vf = build( 

1121 vfDoc, 

1122 master_finder, 

1123 exclude=exclude, 

1124 optimize=optimize, 

1125 colr_layer_reuse=colr_layer_reuse, 

1126 drop_implied_oncurves=drop_implied_oncurves, 

1127 )[0] 

1128 if doBuildStatFromDSv5: 

1129 buildVFStatTable(vf, designspace, name) 

1130 res[name] = vf 

1131 return res 

1132 

1133 

1134def build( 

1135 designspace, 

1136 master_finder=lambda s: s, 

1137 exclude=[], 

1138 optimize=True, 

1139 colr_layer_reuse=True, 

1140 drop_implied_oncurves=False, 

1141): 

1142 """ 

1143 Build variation font from a designspace file. 

1144 

1145 If master_finder is set, it should be a callable that takes master 

1146 filename as found in designspace file and map it to master font 

1147 binary as to be opened (eg. .ttf or .otf). 

1148 """ 

1149 if hasattr(designspace, "sources"): # Assume a DesignspaceDocument 

1150 pass 

1151 else: # Assume a file path 

1152 designspace = DesignSpaceDocument.fromfile(designspace) 

1153 

1154 ds = load_designspace(designspace) 

1155 log.info("Building variable font") 

1156 

1157 log.info("Loading master fonts") 

1158 master_fonts = load_masters(designspace, master_finder) 

1159 

1160 # TODO: 'master_ttfs' is unused except for return value, remove later 

1161 master_ttfs = [] 

1162 for master in master_fonts: 

1163 try: 

1164 master_ttfs.append(master.reader.file.name) 

1165 except AttributeError: 

1166 master_ttfs.append(None) # in-memory fonts have no path 

1167 

1168 if drop_implied_oncurves and "glyf" in master_fonts[ds.base_idx]: 

1169 drop_count = drop_implied_oncurve_points(*master_fonts) 

1170 log.info( 

1171 "Dropped %s on-curve points from simple glyphs in the 'glyf' table", 

1172 drop_count, 

1173 ) 

1174 

1175 # Copy the base master to work from it 

1176 vf = deepcopy(master_fonts[ds.base_idx]) 

1177 

1178 if "DSIG" in vf: 

1179 del vf["DSIG"] 

1180 

1181 # TODO append masters as named-instances as well; needs .designspace change. 

1182 fvar = _add_fvar(vf, ds.axes, ds.instances) 

1183 if "STAT" not in exclude: 

1184 _add_stat(vf) 

1185 

1186 # Map from axis names to axis tags... 

1187 normalized_master_locs = [ 

1188 {ds.axes[k].tag: v for k, v in loc.items()} for loc in ds.normalized_master_locs 

1189 ] 

1190 # From here on, we use fvar axes only 

1191 axisTags = [axis.axisTag for axis in fvar.axes] 

1192 

1193 # Assume single-model for now. 

1194 model = models.VariationModel(normalized_master_locs, axisOrder=axisTags) 

1195 assert 0 == model.mapping[ds.base_idx] 

1196 

1197 log.info("Building variations tables") 

1198 if "avar" not in exclude: 

1199 _add_avar(vf, ds.axes, ds.axisMappings, axisTags) 

1200 if "BASE" not in exclude and "BASE" in vf: 

1201 _add_BASE(vf, model, master_fonts, axisTags) 

1202 if "MVAR" not in exclude: 

1203 _add_MVAR(vf, model, master_fonts, axisTags) 

1204 if "HVAR" not in exclude: 

1205 _add_HVAR(vf, model, master_fonts, axisTags) 

1206 if "VVAR" not in exclude and "vmtx" in vf: 

1207 _add_VVAR(vf, model, master_fonts, axisTags) 

1208 if "GDEF" not in exclude or "GPOS" not in exclude: 

1209 _merge_OTL(vf, model, master_fonts, axisTags) 

1210 if "gvar" not in exclude and "glyf" in vf: 

1211 _add_gvar(vf, model, master_fonts, optimize=optimize) 

1212 if "cvar" not in exclude and "glyf" in vf: 

1213 _merge_TTHinting(vf, model, master_fonts) 

1214 if "GSUB" not in exclude and ds.rules: 

1215 featureTags = _feature_variations_tags(ds) 

1216 _add_GSUB_feature_variations( 

1217 vf, ds.axes, ds.internal_axis_supports, ds.rules, featureTags 

1218 ) 

1219 if "CFF2" not in exclude and ("CFF " in vf or "CFF2" in vf): 

1220 _add_CFF2(vf, model, master_fonts) 

1221 if "post" in vf: 

1222 # set 'post' to format 2 to keep the glyph names dropped from CFF2 

1223 post = vf["post"] 

1224 if post.formatType != 2.0: 

1225 post.formatType = 2.0 

1226 post.extraNames = [] 

1227 post.mapping = {} 

1228 if "COLR" not in exclude and "COLR" in vf and vf["COLR"].version > 0: 

1229 _add_COLR(vf, model, master_fonts, axisTags, colr_layer_reuse) 

1230 

1231 set_default_weight_width_slant( 

1232 vf, location={axis.axisTag: axis.defaultValue for axis in vf["fvar"].axes} 

1233 ) 

1234 

1235 for tag in exclude: 

1236 if tag in vf: 

1237 del vf[tag] 

1238 

1239 # TODO: Only return vf for 4.0+, the rest is unused. 

1240 return vf, model, master_ttfs 

1241 

1242 

1243def _open_font(path, master_finder=lambda s: s): 

1244 # load TTFont masters from given 'path': this can be either a .TTX or an 

1245 # OpenType binary font; or if neither of these, try use the 'master_finder' 

1246 # callable to resolve the path to a valid .TTX or OpenType font binary. 

1247 from fontTools.ttx import guessFileType 

1248 

1249 master_path = os.path.normpath(path) 

1250 tp = guessFileType(master_path) 

1251 if tp is None: 

1252 # not an OpenType binary/ttx, fall back to the master finder. 

1253 master_path = master_finder(master_path) 

1254 tp = guessFileType(master_path) 

1255 if tp in ("TTX", "OTX"): 

1256 font = TTFont() 

1257 font.importXML(master_path) 

1258 elif tp in ("TTF", "OTF", "WOFF", "WOFF2"): 

1259 font = TTFont(master_path) 

1260 else: 

1261 raise VarLibValidationError("Invalid master path: %r" % master_path) 

1262 return font 

1263 

1264 

1265def load_masters(designspace, master_finder=lambda s: s): 

1266 """Ensure that all SourceDescriptor.font attributes have an appropriate TTFont 

1267 object loaded, or else open TTFont objects from the SourceDescriptor.path 

1268 attributes. 

1269 

1270 The paths can point to either an OpenType font, a TTX file, or a UFO. In the 

1271 latter case, use the provided master_finder callable to map from UFO paths to 

1272 the respective master font binaries (e.g. .ttf, .otf or .ttx). 

1273 

1274 Return list of master TTFont objects in the same order they are listed in the 

1275 DesignSpaceDocument. 

1276 """ 

1277 for master in designspace.sources: 

1278 # If a SourceDescriptor has a layer name, demand that the compiled TTFont 

1279 # be supplied by the caller. This spares us from modifying MasterFinder. 

1280 if master.layerName and master.font is None: 

1281 raise VarLibValidationError( 

1282 f"Designspace source '{master.name or '<Unknown>'}' specified a " 

1283 "layer name but lacks the required TTFont object in the 'font' " 

1284 "attribute." 

1285 ) 

1286 

1287 return designspace.loadSourceFonts(_open_font, master_finder=master_finder) 

1288 

1289 

1290class MasterFinder(object): 

1291 def __init__(self, template): 

1292 self.template = template 

1293 

1294 def __call__(self, src_path): 

1295 fullname = os.path.abspath(src_path) 

1296 dirname, basename = os.path.split(fullname) 

1297 stem, ext = os.path.splitext(basename) 

1298 path = self.template.format( 

1299 fullname=fullname, 

1300 dirname=dirname, 

1301 basename=basename, 

1302 stem=stem, 

1303 ext=ext, 

1304 ) 

1305 return os.path.normpath(path) 

1306 

1307 

1308def _feature_variations_tags(ds): 

1309 raw_tags = ds.lib.get( 

1310 FEAVAR_FEATURETAG_LIB_KEY, 

1311 "rclt" if ds.rulesProcessingLast else "rvrn", 

1312 ) 

1313 return sorted({t.strip() for t in raw_tags.split(",")}) 

1314 

1315 

1316def addGSUBFeatureVariations(vf, designspace, featureTags=(), *, log_enabled=False): 

1317 """Add GSUB FeatureVariations table to variable font, based on DesignSpace rules. 

1318 

1319 Args: 

1320 vf: A TTFont object representing the variable font. 

1321 designspace: A DesignSpaceDocument object. 

1322 featureTags: Optional feature tag(s) to use for the FeatureVariations records. 

1323 If unset, the key 'com.github.fonttools.varLib.featureVarsFeatureTag' is 

1324 looked up in the DS <lib> and used; otherwise the default is 'rclt' if 

1325 the <rules processing="last"> attribute is set, else 'rvrn'. 

1326 See <https://fonttools.readthedocs.io/en/latest/designspaceLib/xml.html#rules-element> 

1327 log_enabled: If True, log info about DS axes and sources. Default is False, as 

1328 the same info may have already been logged as part of varLib.build. 

1329 """ 

1330 ds = load_designspace(designspace, log_enabled=log_enabled) 

1331 if not ds.rules: 

1332 return 

1333 if not featureTags: 

1334 featureTags = _feature_variations_tags(ds) 

1335 _add_GSUB_feature_variations( 

1336 vf, ds.axes, ds.internal_axis_supports, ds.rules, featureTags 

1337 ) 

1338 

1339 

1340def main(args=None): 

1341 """Build variable fonts from a designspace file and masters""" 

1342 from argparse import ArgumentParser 

1343 from fontTools import configLogger 

1344 

1345 parser = ArgumentParser(prog="varLib", description=main.__doc__) 

1346 parser.add_argument("designspace") 

1347 output_group = parser.add_mutually_exclusive_group() 

1348 output_group.add_argument( 

1349 "-o", metavar="OUTPUTFILE", dest="outfile", default=None, help="output file" 

1350 ) 

1351 output_group.add_argument( 

1352 "-d", 

1353 "--output-dir", 

1354 metavar="OUTPUTDIR", 

1355 default=None, 

1356 help="output dir (default: same as input designspace file)", 

1357 ) 

1358 parser.add_argument( 

1359 "-x", 

1360 metavar="TAG", 

1361 dest="exclude", 

1362 action="append", 

1363 default=[], 

1364 help="exclude table", 

1365 ) 

1366 parser.add_argument( 

1367 "--disable-iup", 

1368 dest="optimize", 

1369 action="store_false", 

1370 help="do not perform IUP optimization", 

1371 ) 

1372 parser.add_argument( 

1373 "--no-colr-layer-reuse", 

1374 dest="colr_layer_reuse", 

1375 action="store_false", 

1376 help="do not rebuild variable COLR table to optimize COLR layer reuse", 

1377 ) 

1378 parser.add_argument( 

1379 "--drop-implied-oncurves", 

1380 action="store_true", 

1381 help=( 

1382 "drop on-curve points that can be implied when exactly in the middle of " 

1383 "two off-curve points (only applies to TrueType fonts)" 

1384 ), 

1385 ) 

1386 parser.add_argument( 

1387 "--master-finder", 

1388 default="master_ttf_interpolatable/{stem}.ttf", 

1389 help=( 

1390 "templated string used for finding binary font " 

1391 "files given the source file names defined in the " 

1392 "designspace document. The following special strings " 

1393 "are defined: {fullname} is the absolute source file " 

1394 "name; {basename} is the file name without its " 

1395 "directory; {stem} is the basename without the file " 

1396 "extension; {ext} is the source file extension; " 

1397 "{dirname} is the directory of the absolute file " 

1398 'name. The default value is "%(default)s".' 

1399 ), 

1400 ) 

1401 parser.add_argument( 

1402 "--variable-fonts", 

1403 default=".*", 

1404 metavar="VF_NAME", 

1405 help=( 

1406 "Filter the list of variable fonts produced from the input " 

1407 "Designspace v5 file. By default all listed variable fonts are " 

1408 "generated. To generate a specific variable font (or variable fonts) " 

1409 'that match a given "name" attribute, you can pass as argument ' 

1410 "the full name or a regular expression. E.g.: --variable-fonts " 

1411 '"MyFontVF_WeightOnly"; or --variable-fonts "MyFontVFItalic_.*".' 

1412 ), 

1413 ) 

1414 logging_group = parser.add_mutually_exclusive_group(required=False) 

1415 logging_group.add_argument( 

1416 "-v", "--verbose", action="store_true", help="Run more verbosely." 

1417 ) 

1418 logging_group.add_argument( 

1419 "-q", "--quiet", action="store_true", help="Turn verbosity off." 

1420 ) 

1421 options = parser.parse_args(args) 

1422 

1423 configLogger( 

1424 level=("DEBUG" if options.verbose else "ERROR" if options.quiet else "INFO") 

1425 ) 

1426 

1427 designspace_filename = options.designspace 

1428 designspace = DesignSpaceDocument.fromfile(designspace_filename) 

1429 

1430 vf_descriptors = designspace.getVariableFonts() 

1431 if not vf_descriptors: 

1432 parser.error(f"No variable fonts in given designspace {designspace.path!r}") 

1433 

1434 vfs_to_build = [] 

1435 for vf in vf_descriptors: 

1436 # Skip variable fonts that do not match the user's inclusion regex if given. 

1437 if not fullmatch(options.variable_fonts, vf.name): 

1438 continue 

1439 vfs_to_build.append(vf) 

1440 

1441 if not vfs_to_build: 

1442 parser.error(f"No variable fonts matching {options.variable_fonts!r}") 

1443 

1444 if options.outfile is not None and len(vfs_to_build) > 1: 

1445 parser.error( 

1446 "can't specify -o because there are multiple VFs to build; " 

1447 "use --output-dir, or select a single VF with --variable-fonts" 

1448 ) 

1449 

1450 output_dir = options.output_dir 

1451 if output_dir is None: 

1452 output_dir = os.path.dirname(designspace_filename) 

1453 

1454 vf_name_to_output_path = {} 

1455 if len(vfs_to_build) == 1 and options.outfile is not None: 

1456 vf_name_to_output_path[vfs_to_build[0].name] = options.outfile 

1457 else: 

1458 for vf in vfs_to_build: 

1459 filename = vf.filename if vf.filename is not None else vf.name + ".{ext}" 

1460 vf_name_to_output_path[vf.name] = os.path.join(output_dir, filename) 

1461 

1462 finder = MasterFinder(options.master_finder) 

1463 

1464 vfs = build_many( 

1465 designspace, 

1466 finder, 

1467 exclude=options.exclude, 

1468 optimize=options.optimize, 

1469 colr_layer_reuse=options.colr_layer_reuse, 

1470 drop_implied_oncurves=options.drop_implied_oncurves, 

1471 ) 

1472 

1473 for vf_name, vf in vfs.items(): 

1474 ext = "otf" if vf.sfntVersion == "OTTO" else "ttf" 

1475 output_path = vf_name_to_output_path[vf_name].format(ext=ext) 

1476 output_dir = os.path.dirname(output_path) 

1477 if output_dir: 

1478 os.makedirs(output_dir, exist_ok=True) 

1479 log.info("Saving variation font %s", output_path) 

1480 vf.save(output_path) 

1481 

1482 

1483if __name__ == "__main__": 

1484 import sys 

1485 

1486 if len(sys.argv) > 1: 

1487 sys.exit(main()) 

1488 import doctest 

1489 

1490 sys.exit(doctest.testmod().failed)