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
« 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.
5The ideas here are very similar to MutatorMath. There is even code to read
6MutatorMath .designspace files in the varLib.designspace module.
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:
13 $ fontmake -o ttf-interpolatable -g NotoSansArabic-MM.glyphs
15Then you can make a variable-font this way:
17 $ fonttools varLib master_ufo/NotoSansArabic.designspace
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
52log = logging.getLogger("fontTools.varLib")
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"
60#
61# Creation routines
62#
65def _add_fvar(font, axes, instances: List[InstanceDescriptor]):
66 """
67 Add 'fvar' table to font.
69 axes is an ordered dictionary of DesignspaceAxis objects.
71 instances is list of dictionary objects with 'location', 'stylename',
72 and possibly 'postscriptfontname' entries.
73 """
75 assert axes
76 assert isinstance(axes, OrderedDict)
78 log.info("Generating fvar")
80 fvar = newTable("fvar")
81 nameTable = font["name"]
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)
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 }
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
116 psname = instance.postScriptFontName
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)
129 assert "fvar" not in font
130 font["fvar"] = fvar
132 return fvar
135def _add_avar(font, axes, mappings, axisTags):
136 """
137 Add 'avar' table to font.
139 axes is an ordered dictionary of AxisDescriptor objects.
140 """
142 assert axes
143 assert isinstance(axes, OrderedDict)
145 log.info("Generating avar")
147 avar = newTable("avar")
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}
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
165 if not axis.map:
166 continue
168 items = sorted(axis.map)
169 keys = [item[0] for item in items]
170 vals = [item[1] for item in items]
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 )
202 keys = [models.normalizeValue(v, keys_triple) for v in keys]
203 vals = [models.normalizeValue(v, vals_triple) for v in vals]
205 if all(k == v for k, v in zip(keys, vals)):
206 continue
207 interesting = True
209 curve.update(zip(keys, vals))
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})
216 if mappings:
217 interesting = True
219 hiddenAxes = [axis for axis in axes.values() if axis.hidden]
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)
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, {})
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]
256 store = storeBuilder.finish()
257 optimized = store.optimize()
258 varIdxes = {axis: optimized[value] for axis, value in varIdxes.items()}
260 varIdxMap = builder.buildDeltaSetIndexMap(varIdxes[t] for t in axisTags)
262 avar.majorVersion = 2
263 avar.table = ot.avar()
264 avar.table.VarIdxMap = varIdxMap
265 avar.table.VarStore = store
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
274 return avar
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()`
282 if "STAT" in font:
283 return
285 from ..otlLib.builder import buildStatTable
287 fvarTable = font["fvar"]
288 axes = [dict(tag=a.axisTag, name=a.axisNameID) for a in fvarTable.axes]
289 buildStatTable(font, axes)
292_MasterData = namedtuple("_MasterData", ["glyf", "hMetrics", "vMetrics"])
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.")
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]
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 ]
312 for glyph in font.getGlyphOrder():
313 log.debug("building gvar for glyph '%s'", glyph)
314 isComposite = glyf[glyph].isComposite()
316 allData = [
317 m.glyf._getCoordinatesAndControls(glyph, m.hMetrics, m.vMetrics)
318 for m in master_datas
319 ]
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 ]
329 model, allData = masterModel.getSubModel(allData)
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
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)
347 # Prepare for IUP optimization
348 origCoords = deltas[0]
349 endPts = control.endPts
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 )
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.
364 This is to work around an issue with macOS 10.14 and can be
365 removed once the behaviour of macOS is changed.
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)
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)
382 if optimized_len < unoptimized_len:
383 var = var_opt
385 gvar.variations[glyph].append(var)
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?
407def _merge_TTHinting(font, masterModel, master_ttfs):
408 log.info("Merging TT hinting")
409 assert "cvar" not in font
411 # Check that the existing hinting is compatible
413 # fpgm and prep table
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
427 # glyf table
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
446 # cvt table
448 all_cvs = [Vector(m["cvt "].values) if "cvt " in m else None for m in master_ttfs]
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
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
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)
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
477_MetricsFields = namedtuple(
478 "_MetricsFields",
479 ["tableTag", "metricsTag", "sb1", "sb2", "advMapping", "vOrigMapping"],
480)
482HVAR_FIELDS = _MetricsFields(
483 tableTag="HVAR",
484 metricsTag="hmtx",
485 sb1="LsbMap",
486 sb2="RsbMap",
487 advMapping="AdvWidthMap",
488 vOrigMapping=None,
489)
491VVAR_FIELDS = _MetricsFields(
492 tableTag="VVAR",
493 metricsTag="vmtx",
494 sb1="TsbMap",
495 sb2="BsbMap",
496 advMapping="AdvHeightMap",
497 vOrigMapping="VOrgMap",
498)
501def _add_HVAR(font, masterModel, master_ttfs, axisTags):
502 _add_VHVAR(font, masterModel, master_ttfs, axisTags, HVAR_FIELDS)
505def _add_VVAR(font, masterModel, master_ttfs, axisTags):
506 _add_VHVAR(font, masterModel, master_ttfs, axisTags, VVAR_FIELDS)
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
518 glyphOrder = font.getGlyphOrder()
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]
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
532 metricsStore, advanceMapping, vOrigMapping = _get_advance_metrics(
533 font,
534 masterModel,
535 master_ttfs,
536 axisTags,
537 glyphOrder,
538 advMetricses,
539 vOrigMetricses,
540 )
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)
552 font[tableTag] = VHVAR
553 return
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 )
583 singleModel = models.allEqual(id(v[1]) for v in vhAdvanceDeltasAndSupports.values())
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 )
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])
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)
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)
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)
631 if vOrigMetricses:
632 vOrigMap = [mapping2[vOrigMap[g]] for g in glyphOrder]
634 useDirect = False
635 vOrigMapping = None
636 if directStore:
637 # Compile both, see which is more compact
639 writer = OTTableWriter()
640 directStore.compile(writer, font)
641 directSize = len(writer.getAllData())
643 writer = OTTableWriter()
644 indirectStore.compile(writer, font)
645 advanceMapping.compile(writer, font)
646 indirectSize = len(writer.getAllData())
648 useDirect = directSize < indirectSize
650 if useDirect:
651 metricsStore = directStore
652 advanceMapping = None
653 else:
654 metricsStore = indirectStore
655 if vOrigMetricses:
656 vOrigMapping = builder.buildVarIdxMap(vOrigMap, glyphOrder)
658 return metricsStore, advanceMapping, vOrigMapping
661def _add_MVAR(font, masterModel, master_ttfs, axisTags):
662 log.info("Generating MVAR")
664 store_builder = varStore.OnlineVarStoreBuilder(axisTags)
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}
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
700 if tables is None: # Tag not applicable to the master font.
701 continue
703 # TODO support gasp entries
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)
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)
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]
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)
739def _add_BASE(font, masterModel, master_ttfs, axisTags):
740 log.info("Generating BASE")
742 merger = VariationMerger(masterModel, axisTags, font)
743 merger.mergeTables(font, master_ttfs, ["BASE"])
744 store = merger.store_builder.finish()
746 if not store:
747 return
748 base = font["BASE"].table
749 assert base.Version == 0x00010000
750 base.Version = 0x00010001
751 base.VarStore = store
754def _merge_OTL(font, model, master_fonts, axisTags):
755 log.info("Merging OpenType Layout tables")
756 merger = VariationMerger(model, axisTags, font)
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
775 GDEF.Version = 0x00010003
776 GDEF.VarStore = store
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)
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]
791 log.info("Generating GSUB FeatureVariations")
793 axis_tags = {name: axis.tag for name, axis in axes.items()}
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)
814 subs = {k: v for k, v in rule.subs}
816 conditional_subs.append((region, subs))
818 addFeatureVariations(font, conditional_subs, featureTags)
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)
838def _add_CFF2(varFont, model, master_fonts):
839 from .cff import merge_region_fonts
841 glyphOrder = varFont.getGlyphOrder()
842 if "CFF2" not in varFont:
843 from .cff import convertCFFtoCFF2
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)
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()
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)
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)
874 masters = ds.sources
875 if not masters:
876 raise VarLibValidationError("Designspace must have at least one source.")
877 instances = ds.instances
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 )
890 # Setup axes
891 if not ds.axes:
892 raise VarLibValidationError(f"Designspace must have at least one axis.")
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
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)
913 axes[axis_name] = axis
914 if log_enabled:
915 log.info("Axes:\n%s", pformat([axis.asdict() for axis in axes.values()]))
917 axisMappings = ds.axisMappings
918 if axisMappings and log_enabled:
919 log.info("Mappings:\n%s", pformat(axisMappings))
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 )
945 # Normalize master locations
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))
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))
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))
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)
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 )
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}
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
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
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
1035def drop_implied_oncurve_points(*masters: TTFont) -> int:
1036 """Drop impliable on-curve points from all the simple glyphs in masters.
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.
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.
1045 Args:
1046 masters: The TTFont(s) to modify
1048 Returns:
1049 The total number of points that were dropped if any.
1051 Reference:
1052 https://developer.apple.com/fonts/TrueType-Reference-Manual/RM01/Chap1.html
1053 """
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
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.
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).
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`.
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
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.
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)
1154 ds = load_designspace(designspace)
1155 log.info("Building variable font")
1157 log.info("Loading master fonts")
1158 master_fonts = load_masters(designspace, master_finder)
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
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 )
1175 # Copy the base master to work from it
1176 vf = deepcopy(master_fonts[ds.base_idx])
1178 if "DSIG" in vf:
1179 del vf["DSIG"]
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)
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]
1193 # Assume single-model for now.
1194 model = models.VariationModel(normalized_master_locs, axisOrder=axisTags)
1195 assert 0 == model.mapping[ds.base_idx]
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)
1231 set_default_weight_width_slant(
1232 vf, location={axis.axisTag: axis.defaultValue for axis in vf["fvar"].axes}
1233 )
1235 for tag in exclude:
1236 if tag in vf:
1237 del vf[tag]
1239 # TODO: Only return vf for 4.0+, the rest is unused.
1240 return vf, model, master_ttfs
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
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
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.
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).
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 )
1287 return designspace.loadSourceFonts(_open_font, master_finder=master_finder)
1290class MasterFinder(object):
1291 def __init__(self, template):
1292 self.template = template
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)
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(",")})
1316def addGSUBFeatureVariations(vf, designspace, featureTags=(), *, log_enabled=False):
1317 """Add GSUB FeatureVariations table to variable font, based on DesignSpace rules.
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 )
1340def main(args=None):
1341 """Build variable fonts from a designspace file and masters"""
1342 from argparse import ArgumentParser
1343 from fontTools import configLogger
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)
1423 configLogger(
1424 level=("DEBUG" if options.verbose else "ERROR" if options.quiet else "INFO")
1425 )
1427 designspace_filename = options.designspace
1428 designspace = DesignSpaceDocument.fromfile(designspace_filename)
1430 vf_descriptors = designspace.getVariableFonts()
1431 if not vf_descriptors:
1432 parser.error(f"No variable fonts in given designspace {designspace.path!r}")
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)
1441 if not vfs_to_build:
1442 parser.error(f"No variable fonts matching {options.variable_fonts!r}")
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 )
1450 output_dir = options.output_dir
1451 if output_dir is None:
1452 output_dir = os.path.dirname(designspace_filename)
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)
1462 finder = MasterFinder(options.master_finder)
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 )
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)
1483if __name__ == "__main__":
1484 import sys
1486 if len(sys.argv) > 1:
1487 sys.exit(main())
1488 import doctest
1490 sys.exit(doctest.testmod().failed)