diff options
Diffstat (limited to 'Lib/fontTools/varLib/__init__.py')
-rw-r--r-- | Lib/fontTools/varLib/__init__.py | 2346 |
1 files changed, 1310 insertions, 1036 deletions
diff --git a/Lib/fontTools/varLib/__init__.py b/Lib/fontTools/varLib/__init__.py index f1ca99ff..b130d5b2 100644 --- a/Lib/fontTools/varLib/__init__.py +++ b/Lib/fontTools/varLib/__init__.py @@ -21,10 +21,11 @@ API *will* change in near future. from typing import List from fontTools.misc.vector import Vector from fontTools.misc.roundTools import noRound, otRound +from fontTools.misc.fixedTools import floatToFixed as fl2fi from fontTools.misc.textTools import Tag, tostr from fontTools.ttLib import TTFont, newTable from fontTools.ttLib.tables._f_v_a_r import Axis, NamedInstance -from fontTools.ttLib.tables._g_l_y_f import GlyphCoordinates +from fontTools.ttLib.tables._g_l_y_f import GlyphCoordinates, dropImpliedOnCurvePoints from fontTools.ttLib.tables.ttProgram import Program from fontTools.ttLib.tables.TupleVariation import TupleVariation from fontTools.ttLib.tables import otTables as ot @@ -40,11 +41,12 @@ from fontTools.varLib.stat import buildVFStatTable from fontTools.colorLib.builder import buildColrV1 from fontTools.colorLib.unbuilder import unbuildColrV1 from functools import partial -from collections import OrderedDict, namedtuple +from collections import OrderedDict, defaultdict, namedtuple import os.path import logging from copy import deepcopy from pprint import pformat +from re import fullmatch from .errors import VarLibError, VarLibValidationError log = logging.getLogger("fontTools.varLib") @@ -58,1121 +60,1393 @@ FEAVAR_FEATURETAG_LIB_KEY = "com.github.fonttools.varLib.featureVarsFeatureTag" # Creation routines # + def _add_fvar(font, axes, instances: List[InstanceDescriptor]): - """ - Add 'fvar' table to font. - - axes is an ordered dictionary of DesignspaceAxis objects. - - instances is list of dictionary objects with 'location', 'stylename', - and possibly 'postscriptfontname' entries. - """ - - assert axes - assert isinstance(axes, OrderedDict) - - log.info("Generating fvar") - - fvar = newTable('fvar') - nameTable = font['name'] - - for a in axes.values(): - axis = Axis() - axis.axisTag = Tag(a.tag) - # TODO Skip axes that have no variation. - axis.minValue, axis.defaultValue, axis.maxValue = a.minimum, a.default, a.maximum - axis.axisNameID = nameTable.addMultilingualName(a.labelNames, font, minNameID=256) - axis.flags = int(a.hidden) - fvar.axes.append(axis) - - for instance in instances: - # Filter out discrete axis locations - coordinates = {name: value for name, value in instance.location.items() if name in axes} - - if "en" not in instance.localisedStyleName: - if not instance.styleName: - raise VarLibValidationError( - f"Instance at location '{coordinates}' must have a default English " - "style name ('stylename' attribute on the instance element or a " - "stylename element with an 'xml:lang=\"en\"' attribute)." - ) - localisedStyleName = dict(instance.localisedStyleName) - localisedStyleName["en"] = tostr(instance.styleName) - else: - localisedStyleName = instance.localisedStyleName - - psname = instance.postScriptFontName - - inst = NamedInstance() - inst.subfamilyNameID = nameTable.addMultilingualName(localisedStyleName) - if psname is not None: - psname = tostr(psname) - inst.postscriptNameID = nameTable.addName(psname) - inst.coordinates = {axes[k].tag:axes[k].map_backward(v) for k,v in coordinates.items()} - #inst.coordinates = {axes[k].tag:v for k,v in coordinates.items()} - fvar.instances.append(inst) - - assert "fvar" not in font - font['fvar'] = fvar - - return fvar - -def _add_avar(font, axes): - """ - Add 'avar' table to font. - - axes is an ordered dictionary of AxisDescriptor objects. - """ - - assert axes - assert isinstance(axes, OrderedDict) - - log.info("Generating avar") - - avar = newTable('avar') - - interesting = False - for axis in axes.values(): - # Currently, some rasterizers require that the default value maps - # (-1 to -1, 0 to 0, and 1 to 1) be present for all the segment - # maps, even when the default normalization mapping for the axis - # was not modified. - # https://github.com/googlei18n/fontmake/issues/295 - # https://github.com/fonttools/fonttools/issues/1011 - # TODO(anthrotype) revert this (and 19c4b37) when issue is fixed - curve = avar.segments[axis.tag] = {-1.0: -1.0, 0.0: 0.0, 1.0: 1.0} - if not axis.map: - continue - - items = sorted(axis.map) - keys = [item[0] for item in items] - vals = [item[1] for item in items] - - # Current avar requirements. We don't have to enforce - # these on the designer and can deduce some ourselves, - # but for now just enforce them. - if axis.minimum != min(keys): - raise VarLibValidationError( - f"Axis '{axis.name}': there must be a mapping for the axis minimum " - f"value {axis.minimum} and it must be the lowest input mapping value." - ) - if axis.maximum != max(keys): - raise VarLibValidationError( - f"Axis '{axis.name}': there must be a mapping for the axis maximum " - f"value {axis.maximum} and it must be the highest input mapping value." - ) - if axis.default not in keys: - raise VarLibValidationError( - f"Axis '{axis.name}': there must be a mapping for the axis default " - f"value {axis.default}." - ) - # No duplicate input values (output values can be >= their preceeding value). - if len(set(keys)) != len(keys): - raise VarLibValidationError( - f"Axis '{axis.name}': All axis mapping input='...' values must be " - "unique, but we found duplicates." - ) - # Ascending values - if sorted(vals) != vals: - raise VarLibValidationError( - f"Axis '{axis.name}': mapping output values must be in ascending order." - ) - - keys_triple = (axis.minimum, axis.default, axis.maximum) - vals_triple = tuple(axis.map_forward(v) for v in keys_triple) - - keys = [models.normalizeValue(v, keys_triple) for v in keys] - vals = [models.normalizeValue(v, vals_triple) for v in vals] - - if all(k == v for k, v in zip(keys, vals)): - continue - interesting = True - - curve.update(zip(keys, vals)) - - assert 0.0 in curve and curve[0.0] == 0.0 - assert -1.0 not in curve or curve[-1.0] == -1.0 - assert +1.0 not in curve or curve[+1.0] == +1.0 - # curve.update({-1.0: -1.0, 0.0: 0.0, 1.0: 1.0}) - - assert "avar" not in font - if not interesting: - log.info("No need for avar") - avar = None - else: - font['avar'] = avar - - return avar + """ + Add 'fvar' table to font. + + axes is an ordered dictionary of DesignspaceAxis objects. + + instances is list of dictionary objects with 'location', 'stylename', + and possibly 'postscriptfontname' entries. + """ + + assert axes + assert isinstance(axes, OrderedDict) + + log.info("Generating fvar") + + fvar = newTable("fvar") + nameTable = font["name"] + + for a in axes.values(): + axis = Axis() + axis.axisTag = Tag(a.tag) + # TODO Skip axes that have no variation. + axis.minValue, axis.defaultValue, axis.maxValue = ( + a.minimum, + a.default, + a.maximum, + ) + axis.axisNameID = nameTable.addMultilingualName( + a.labelNames, font, minNameID=256 + ) + axis.flags = int(a.hidden) + fvar.axes.append(axis) + + for instance in instances: + # Filter out discrete axis locations + coordinates = { + name: value for name, value in instance.location.items() if name in axes + } + + if "en" not in instance.localisedStyleName: + if not instance.styleName: + raise VarLibValidationError( + f"Instance at location '{coordinates}' must have a default English " + "style name ('stylename' attribute on the instance element or a " + "stylename element with an 'xml:lang=\"en\"' attribute)." + ) + localisedStyleName = dict(instance.localisedStyleName) + localisedStyleName["en"] = tostr(instance.styleName) + else: + localisedStyleName = instance.localisedStyleName + + psname = instance.postScriptFontName + + inst = NamedInstance() + inst.subfamilyNameID = nameTable.addMultilingualName(localisedStyleName) + if psname is not None: + psname = tostr(psname) + inst.postscriptNameID = nameTable.addName(psname) + inst.coordinates = { + axes[k].tag: axes[k].map_backward(v) for k, v in coordinates.items() + } + # inst.coordinates = {axes[k].tag:v for k,v in coordinates.items()} + fvar.instances.append(inst) + + assert "fvar" not in font + font["fvar"] = fvar + + return fvar + + +def _add_avar(font, axes, mappings, axisTags): + """ + Add 'avar' table to font. + + axes is an ordered dictionary of AxisDescriptor objects. + """ + + assert axes + assert isinstance(axes, OrderedDict) + + log.info("Generating avar") + + avar = newTable("avar") + + interesting = False + vals_triples = {} + for axis in axes.values(): + # Currently, some rasterizers require that the default value maps + # (-1 to -1, 0 to 0, and 1 to 1) be present for all the segment + # maps, even when the default normalization mapping for the axis + # was not modified. + # https://github.com/googlei18n/fontmake/issues/295 + # https://github.com/fonttools/fonttools/issues/1011 + # TODO(anthrotype) revert this (and 19c4b37) when issue is fixed + curve = avar.segments[axis.tag] = {-1.0: -1.0, 0.0: 0.0, 1.0: 1.0} + + keys_triple = (axis.minimum, axis.default, axis.maximum) + vals_triple = tuple(axis.map_forward(v) for v in keys_triple) + vals_triples[axis.tag] = vals_triple + + if not axis.map: + continue + + items = sorted(axis.map) + keys = [item[0] for item in items] + vals = [item[1] for item in items] + + # Current avar requirements. We don't have to enforce + # these on the designer and can deduce some ourselves, + # but for now just enforce them. + if axis.minimum != min(keys): + raise VarLibValidationError( + f"Axis '{axis.name}': there must be a mapping for the axis minimum " + f"value {axis.minimum} and it must be the lowest input mapping value." + ) + if axis.maximum != max(keys): + raise VarLibValidationError( + f"Axis '{axis.name}': there must be a mapping for the axis maximum " + f"value {axis.maximum} and it must be the highest input mapping value." + ) + if axis.default not in keys: + raise VarLibValidationError( + f"Axis '{axis.name}': there must be a mapping for the axis default " + f"value {axis.default}." + ) + # No duplicate input values (output values can be >= their preceeding value). + if len(set(keys)) != len(keys): + raise VarLibValidationError( + f"Axis '{axis.name}': All axis mapping input='...' values must be " + "unique, but we found duplicates." + ) + # Ascending values + if sorted(vals) != vals: + raise VarLibValidationError( + f"Axis '{axis.name}': mapping output values must be in ascending order." + ) + + keys = [models.normalizeValue(v, keys_triple) for v in keys] + vals = [models.normalizeValue(v, vals_triple) for v in vals] + + if all(k == v for k, v in zip(keys, vals)): + continue + interesting = True + + curve.update(zip(keys, vals)) + + assert 0.0 in curve and curve[0.0] == 0.0 + assert -1.0 not in curve or curve[-1.0] == -1.0 + assert +1.0 not in curve or curve[+1.0] == +1.0 + # curve.update({-1.0: -1.0, 0.0: 0.0, 1.0: 1.0}) + + if mappings: + interesting = True + + hiddenAxes = [axis for axis in axes.values() if axis.hidden] + + inputLocations = [ + { + axes[name].tag: models.normalizeValue(v, vals_triples[axes[name].tag]) + for name, v in mapping.inputLocation.items() + } + for mapping in mappings + ] + outputLocations = [ + { + axes[name].tag: models.normalizeValue(v, vals_triples[axes[name].tag]) + for name, v in mapping.outputLocation.items() + } + for mapping in mappings + ] + assert len(inputLocations) == len(outputLocations) + + # If base-master is missing, insert it at zero location. + if not any(all(v == 0 for k, v in loc.items()) for loc in inputLocations): + inputLocations.insert(0, {}) + outputLocations.insert(0, {}) + + model = models.VariationModel(inputLocations, axisTags) + storeBuilder = varStore.OnlineVarStoreBuilder(axisTags) + storeBuilder.setModel(model) + varIdxes = {} + for tag in axisTags: + masterValues = [] + for vo, vi in zip(outputLocations, inputLocations): + if tag not in vo: + masterValues.append(0) + continue + v = vo[tag] - vi.get(tag, 0) + masterValues.append(fl2fi(v, 14)) + varIdxes[tag] = storeBuilder.storeMasters(masterValues)[1] + + store = storeBuilder.finish() + optimized = store.optimize() + varIdxes = {axis: optimized[value] for axis, value in varIdxes.items()} + + varIdxMap = builder.buildDeltaSetIndexMap(varIdxes[t] for t in axisTags) + + avar.majorVersion = 2 + avar.table = ot.avar() + avar.table.VarIdxMap = varIdxMap + avar.table.VarStore = store + + assert "avar" not in font + if not interesting: + log.info("No need for avar") + avar = None + else: + font["avar"] = avar + + return avar + def _add_stat(font): - # Note: this function only gets called by old code that calls `build()` - # directly. Newer code that wants to benefit from STAT data from the - # designspace should call `build_many()` + # Note: this function only gets called by old code that calls `build()` + # directly. Newer code that wants to benefit from STAT data from the + # designspace should call `build_many()` + + if "STAT" in font: + return + + from ..otlLib.builder import buildStatTable - if "STAT" in font: - return + fvarTable = font["fvar"] + axes = [dict(tag=a.axisTag, name=a.axisNameID) for a in fvarTable.axes] + buildStatTable(font, axes) - from ..otlLib.builder import buildStatTable - fvarTable = font['fvar'] - axes = [dict(tag=a.axisTag, name=a.axisNameID) for a in fvarTable.axes] - buildStatTable(font, axes) -_MasterData = namedtuple('_MasterData', ['glyf', 'hMetrics', 'vMetrics']) +_MasterData = namedtuple("_MasterData", ["glyf", "hMetrics", "vMetrics"]) + def _add_gvar(font, masterModel, master_ttfs, tolerance=0.5, optimize=True): - if tolerance < 0: - raise ValueError("`tolerance` must be a positive number.") - - log.info("Generating gvar") - assert "gvar" not in font - gvar = font["gvar"] = newTable('gvar') - glyf = font['glyf'] - defaultMasterIndex = masterModel.reverseMapping[0] - - master_datas = [_MasterData(m['glyf'], - m['hmtx'].metrics, - getattr(m.get('vmtx'), 'metrics', None)) - for m in master_ttfs] - - for glyph in font.getGlyphOrder(): - log.debug("building gvar for glyph '%s'", glyph) - isComposite = glyf[glyph].isComposite() - - allData = [ - m.glyf._getCoordinatesAndControls(glyph, m.hMetrics, m.vMetrics) - for m in master_datas - ] - - if allData[defaultMasterIndex][1].numberOfContours != 0: - # If the default master is not empty, interpret empty non-default masters - # as missing glyphs from a sparse master - allData = [ - d if d is not None and d[1].numberOfContours != 0 else None - for d in allData - ] - - model, allData = masterModel.getSubModel(allData) - - allCoords = [d[0] for d in allData] - allControls = [d[1] for d in allData] - control = allControls[0] - if not models.allEqual(allControls): - log.warning("glyph %s has incompatible masters; skipping" % glyph) - continue - del allControls - - # Update gvar - gvar.variations[glyph] = [] - deltas = model.getDeltas(allCoords, round=partial(GlyphCoordinates.__round__, round=round)) - supports = model.supports - assert len(deltas) == len(supports) - - # Prepare for IUP optimization - origCoords = deltas[0] - endPts = control.endPts - - for i,(delta,support) in enumerate(zip(deltas[1:], supports[1:])): - if all(v == 0 for v in delta.array) and not isComposite: - continue - var = TupleVariation(support, delta) - if optimize: - delta_opt = iup_delta_optimize(delta, origCoords, endPts, tolerance=tolerance) - - if None in delta_opt: - """In composite glyphs, there should be one 0 entry - to make sure the gvar entry is written to the font. - - This is to work around an issue with macOS 10.14 and can be - removed once the behaviour of macOS is changed. - - https://github.com/fonttools/fonttools/issues/1381 - """ - if all(d is None for d in delta_opt): - delta_opt = [(0, 0)] + [None] * (len(delta_opt) - 1) - # Use "optimized" version only if smaller... - var_opt = TupleVariation(support, delta_opt) - - axis_tags = sorted(support.keys()) # Shouldn't matter that this is different from fvar...? - tupleData, auxData = var.compile(axis_tags) - unoptimized_len = len(tupleData) + len(auxData) - tupleData, auxData = var_opt.compile(axis_tags) - optimized_len = len(tupleData) + len(auxData) - - if optimized_len < unoptimized_len: - var = var_opt - - gvar.variations[glyph].append(var) + if tolerance < 0: + raise ValueError("`tolerance` must be a positive number.") + + log.info("Generating gvar") + assert "gvar" not in font + gvar = font["gvar"] = newTable("gvar") + glyf = font["glyf"] + defaultMasterIndex = masterModel.reverseMapping[0] + + master_datas = [ + _MasterData( + m["glyf"], m["hmtx"].metrics, getattr(m.get("vmtx"), "metrics", None) + ) + for m in master_ttfs + ] + + for glyph in font.getGlyphOrder(): + log.debug("building gvar for glyph '%s'", glyph) + isComposite = glyf[glyph].isComposite() + + allData = [ + m.glyf._getCoordinatesAndControls(glyph, m.hMetrics, m.vMetrics) + for m in master_datas + ] + + if allData[defaultMasterIndex][1].numberOfContours != 0: + # If the default master is not empty, interpret empty non-default masters + # as missing glyphs from a sparse master + allData = [ + d if d is not None and d[1].numberOfContours != 0 else None + for d in allData + ] + + model, allData = masterModel.getSubModel(allData) + + allCoords = [d[0] for d in allData] + allControls = [d[1] for d in allData] + control = allControls[0] + if not models.allEqual(allControls): + log.warning("glyph %s has incompatible masters; skipping" % glyph) + continue + del allControls + + # Update gvar + gvar.variations[glyph] = [] + deltas = model.getDeltas( + allCoords, round=partial(GlyphCoordinates.__round__, round=round) + ) + supports = model.supports + assert len(deltas) == len(supports) + + # Prepare for IUP optimization + origCoords = deltas[0] + endPts = control.endPts + + for i, (delta, support) in enumerate(zip(deltas[1:], supports[1:])): + if all(v == 0 for v in delta.array) and not isComposite: + continue + var = TupleVariation(support, delta) + if optimize: + delta_opt = iup_delta_optimize( + delta, origCoords, endPts, tolerance=tolerance + ) + + if None in delta_opt: + """In composite glyphs, there should be one 0 entry + to make sure the gvar entry is written to the font. + + This is to work around an issue with macOS 10.14 and can be + removed once the behaviour of macOS is changed. + + https://github.com/fonttools/fonttools/issues/1381 + """ + if all(d is None for d in delta_opt): + delta_opt = [(0, 0)] + [None] * (len(delta_opt) - 1) + # Use "optimized" version only if smaller... + var_opt = TupleVariation(support, delta_opt) + + axis_tags = sorted( + support.keys() + ) # Shouldn't matter that this is different from fvar...? + tupleData, auxData = var.compile(axis_tags) + unoptimized_len = len(tupleData) + len(auxData) + tupleData, auxData = var_opt.compile(axis_tags) + optimized_len = len(tupleData) + len(auxData) + + if optimized_len < unoptimized_len: + var = var_opt + + gvar.variations[glyph].append(var) def _remove_TTHinting(font): - for tag in ("cvar", "cvt ", "fpgm", "prep"): - if tag in font: - del font[tag] - maxp = font['maxp'] - for attr in ("maxTwilightPoints", "maxStorage", "maxFunctionDefs", "maxInstructionDefs", "maxStackElements", "maxSizeOfInstructions"): - setattr(maxp, attr, 0) - maxp.maxZones = 1 - font["glyf"].removeHinting() - # TODO: Modify gasp table to deactivate gridfitting for all ranges? + for tag in ("cvar", "cvt ", "fpgm", "prep"): + if tag in font: + del font[tag] + maxp = font["maxp"] + for attr in ( + "maxTwilightPoints", + "maxStorage", + "maxFunctionDefs", + "maxInstructionDefs", + "maxStackElements", + "maxSizeOfInstructions", + ): + setattr(maxp, attr, 0) + maxp.maxZones = 1 + font["glyf"].removeHinting() + # TODO: Modify gasp table to deactivate gridfitting for all ranges? + def _merge_TTHinting(font, masterModel, master_ttfs): + log.info("Merging TT hinting") + assert "cvar" not in font + + # Check that the existing hinting is compatible + + # fpgm and prep table + + for tag in ("fpgm", "prep"): + all_pgms = [m[tag].program for m in master_ttfs if tag in m] + if not all_pgms: + continue + font_pgm = getattr(font.get(tag), "program", None) + if any(pgm != font_pgm for pgm in all_pgms): + log.warning( + "Masters have incompatible %s tables, hinting is discarded." % tag + ) + _remove_TTHinting(font) + return + + # glyf table + + font_glyf = font["glyf"] + master_glyfs = [m["glyf"] for m in master_ttfs] + for name, glyph in font_glyf.glyphs.items(): + all_pgms = [getattr(glyf.get(name), "program", None) for glyf in master_glyfs] + if not any(all_pgms): + continue + glyph.expand(font_glyf) + font_pgm = getattr(glyph, "program", None) + if any(pgm != font_pgm for pgm in all_pgms if pgm): + log.warning( + "Masters have incompatible glyph programs in glyph '%s', hinting is discarded." + % name + ) + # TODO Only drop hinting from this glyph. + _remove_TTHinting(font) + return + + # cvt table + + all_cvs = [Vector(m["cvt "].values) if "cvt " in m else None for m in master_ttfs] + + nonNone_cvs = models.nonNone(all_cvs) + if not nonNone_cvs: + # There is no cvt table to make a cvar table from, we're done here. + return + + if not models.allEqual(len(c) for c in nonNone_cvs): + log.warning("Masters have incompatible cvt tables, hinting is discarded.") + _remove_TTHinting(font) + return + + variations = [] + deltas, supports = masterModel.getDeltasAndSupports( + all_cvs, round=round + ) # builtin round calls into Vector.__round__, which uses builtin round as we like + for i, (delta, support) in enumerate(zip(deltas[1:], supports[1:])): + if all(v == 0 for v in delta): + continue + var = TupleVariation(support, delta) + variations.append(var) + + # We can build the cvar table now. + if variations: + cvar = font["cvar"] = newTable("cvar") + cvar.version = 1 + cvar.variations = variations + + +_MetricsFields = namedtuple( + "_MetricsFields", + ["tableTag", "metricsTag", "sb1", "sb2", "advMapping", "vOrigMapping"], +) + +HVAR_FIELDS = _MetricsFields( + tableTag="HVAR", + metricsTag="hmtx", + sb1="LsbMap", + sb2="RsbMap", + advMapping="AdvWidthMap", + vOrigMapping=None, +) + +VVAR_FIELDS = _MetricsFields( + tableTag="VVAR", + metricsTag="vmtx", + sb1="TsbMap", + sb2="BsbMap", + advMapping="AdvHeightMap", + vOrigMapping="VOrgMap", +) - log.info("Merging TT hinting") - assert "cvar" not in font - - # Check that the existing hinting is compatible - - # fpgm and prep table - - for tag in ("fpgm", "prep"): - all_pgms = [m[tag].program for m in master_ttfs if tag in m] - if not all_pgms: - continue - font_pgm = getattr(font.get(tag), 'program', None) - if any(pgm != font_pgm for pgm in all_pgms): - log.warning("Masters have incompatible %s tables, hinting is discarded." % tag) - _remove_TTHinting(font) - return - - # glyf table - - font_glyf = font['glyf'] - master_glyfs = [m['glyf'] for m in master_ttfs] - for name, glyph in font_glyf.glyphs.items(): - all_pgms = [ - getattr(glyf.get(name), 'program', None) - for glyf in master_glyfs - ] - if not any(all_pgms): - continue - glyph.expand(font_glyf) - font_pgm = getattr(glyph, 'program', None) - if any(pgm != font_pgm for pgm in all_pgms if pgm): - log.warning("Masters have incompatible glyph programs in glyph '%s', hinting is discarded." % name) - # TODO Only drop hinting from this glyph. - _remove_TTHinting(font) - return - - # cvt table - - all_cvs = [Vector(m["cvt "].values) if 'cvt ' in m else None - for m in master_ttfs] - - nonNone_cvs = models.nonNone(all_cvs) - if not nonNone_cvs: - # There is no cvt table to make a cvar table from, we're done here. - return - - if not models.allEqual(len(c) for c in nonNone_cvs): - log.warning("Masters have incompatible cvt tables, hinting is discarded.") - _remove_TTHinting(font) - return - - variations = [] - deltas, supports = masterModel.getDeltasAndSupports(all_cvs, round=round) # builtin round calls into Vector.__round__, which uses builtin round as we like - for i,(delta,support) in enumerate(zip(deltas[1:], supports[1:])): - if all(v == 0 for v in delta): - continue - var = TupleVariation(support, delta) - variations.append(var) - - # We can build the cvar table now. - if variations: - cvar = font["cvar"] = newTable('cvar') - cvar.version = 1 - cvar.variations = variations - - -_MetricsFields = namedtuple('_MetricsFields', - ['tableTag', 'metricsTag', 'sb1', 'sb2', 'advMapping', 'vOrigMapping']) - -HVAR_FIELDS = _MetricsFields(tableTag='HVAR', metricsTag='hmtx', sb1='LsbMap', - sb2='RsbMap', advMapping='AdvWidthMap', vOrigMapping=None) - -VVAR_FIELDS = _MetricsFields(tableTag='VVAR', metricsTag='vmtx', sb1='TsbMap', - sb2='BsbMap', advMapping='AdvHeightMap', vOrigMapping='VOrgMap') def _add_HVAR(font, masterModel, master_ttfs, axisTags): - _add_VHVAR(font, masterModel, master_ttfs, axisTags, HVAR_FIELDS) + _add_VHVAR(font, masterModel, master_ttfs, axisTags, HVAR_FIELDS) + def _add_VVAR(font, masterModel, master_ttfs, axisTags): - _add_VHVAR(font, masterModel, master_ttfs, axisTags, VVAR_FIELDS) + _add_VHVAR(font, masterModel, master_ttfs, axisTags, VVAR_FIELDS) + def _add_VHVAR(font, masterModel, master_ttfs, axisTags, tableFields): + tableTag = tableFields.tableTag + assert tableTag not in font + log.info("Generating " + tableTag) + VHVAR = newTable(tableTag) + tableClass = getattr(ot, tableTag) + vhvar = VHVAR.table = tableClass() + vhvar.Version = 0x00010000 + + glyphOrder = font.getGlyphOrder() + + # Build list of source font advance widths for each glyph + metricsTag = tableFields.metricsTag + advMetricses = [m[metricsTag].metrics for m in master_ttfs] + + # Build list of source font vertical origin coords for each glyph + if tableTag == "VVAR" and "VORG" in master_ttfs[0]: + vOrigMetricses = [m["VORG"].VOriginRecords for m in master_ttfs] + defaultYOrigs = [m["VORG"].defaultVertOriginY for m in master_ttfs] + vOrigMetricses = list(zip(vOrigMetricses, defaultYOrigs)) + else: + vOrigMetricses = None + + metricsStore, advanceMapping, vOrigMapping = _get_advance_metrics( + font, + masterModel, + master_ttfs, + axisTags, + glyphOrder, + advMetricses, + vOrigMetricses, + ) + + vhvar.VarStore = metricsStore + if advanceMapping is None: + setattr(vhvar, tableFields.advMapping, None) + else: + setattr(vhvar, tableFields.advMapping, advanceMapping) + if vOrigMapping is not None: + setattr(vhvar, tableFields.vOrigMapping, vOrigMapping) + setattr(vhvar, tableFields.sb1, None) + setattr(vhvar, tableFields.sb2, None) + + font[tableTag] = VHVAR + return + + +def _get_advance_metrics( + font, + masterModel, + master_ttfs, + axisTags, + glyphOrder, + advMetricses, + vOrigMetricses=None, +): + vhAdvanceDeltasAndSupports = {} + vOrigDeltasAndSupports = {} + # HACK: we treat width 65535 as a sentinel value to signal that a glyph + # from a non-default master should not participate in computing {H,V}VAR, + # as if it were missing. Allows to variate other glyph-related data independently + # from glyph metrics + sparse_advance = 0xFFFF + for glyph in glyphOrder: + vhAdvances = [ + metrics[glyph][0] + if glyph in metrics and metrics[glyph][0] != sparse_advance + else None + for metrics in advMetricses + ] + vhAdvanceDeltasAndSupports[glyph] = masterModel.getDeltasAndSupports( + vhAdvances, round=round + ) + + singleModel = models.allEqual(id(v[1]) for v in vhAdvanceDeltasAndSupports.values()) + + if vOrigMetricses: + singleModel = False + for glyph in glyphOrder: + # We need to supply a vOrigs tuple with non-None default values + # for each glyph. vOrigMetricses contains values only for those + # glyphs which have a non-default vOrig. + vOrigs = [ + metrics[glyph] if glyph in metrics else defaultVOrig + for metrics, defaultVOrig in vOrigMetricses + ] + vOrigDeltasAndSupports[glyph] = masterModel.getDeltasAndSupports( + vOrigs, round=round + ) + + directStore = None + if singleModel: + # Build direct mapping + supports = next(iter(vhAdvanceDeltasAndSupports.values()))[1][1:] + varTupleList = builder.buildVarRegionList(supports, axisTags) + varTupleIndexes = list(range(len(supports))) + varData = builder.buildVarData(varTupleIndexes, [], optimize=False) + for glyphName in glyphOrder: + varData.addItem(vhAdvanceDeltasAndSupports[glyphName][0], round=noRound) + varData.optimize() + directStore = builder.buildVarStore(varTupleList, [varData]) + + # Build optimized indirect mapping + storeBuilder = varStore.OnlineVarStoreBuilder(axisTags) + advMapping = {} + for glyphName in glyphOrder: + deltas, supports = vhAdvanceDeltasAndSupports[glyphName] + storeBuilder.setSupports(supports) + advMapping[glyphName] = storeBuilder.storeDeltas(deltas, round=noRound) + + if vOrigMetricses: + vOrigMap = {} + for glyphName in glyphOrder: + deltas, supports = vOrigDeltasAndSupports[glyphName] + storeBuilder.setSupports(supports) + vOrigMap[glyphName] = storeBuilder.storeDeltas(deltas, round=noRound) + + indirectStore = storeBuilder.finish() + mapping2 = indirectStore.optimize(use_NO_VARIATION_INDEX=False) + advMapping = [mapping2[advMapping[g]] for g in glyphOrder] + advanceMapping = builder.buildVarIdxMap(advMapping, glyphOrder) + + if vOrigMetricses: + vOrigMap = [mapping2[vOrigMap[g]] for g in glyphOrder] + + useDirect = False + vOrigMapping = None + if directStore: + # Compile both, see which is more compact + + writer = OTTableWriter() + directStore.compile(writer, font) + directSize = len(writer.getAllData()) + + writer = OTTableWriter() + indirectStore.compile(writer, font) + advanceMapping.compile(writer, font) + indirectSize = len(writer.getAllData()) + + useDirect = directSize < indirectSize + + if useDirect: + metricsStore = directStore + advanceMapping = None + else: + metricsStore = indirectStore + if vOrigMetricses: + vOrigMapping = builder.buildVarIdxMap(vOrigMap, glyphOrder) + + return metricsStore, advanceMapping, vOrigMapping - tableTag = tableFields.tableTag - assert tableTag not in font - log.info("Generating " + tableTag) - VHVAR = newTable(tableTag) - tableClass = getattr(ot, tableTag) - vhvar = VHVAR.table = tableClass() - vhvar.Version = 0x00010000 - - glyphOrder = font.getGlyphOrder() - - # Build list of source font advance widths for each glyph - metricsTag = tableFields.metricsTag - advMetricses = [m[metricsTag].metrics for m in master_ttfs] - - # Build list of source font vertical origin coords for each glyph - if tableTag == 'VVAR' and 'VORG' in master_ttfs[0]: - vOrigMetricses = [m['VORG'].VOriginRecords for m in master_ttfs] - defaultYOrigs = [m['VORG'].defaultVertOriginY for m in master_ttfs] - vOrigMetricses = list(zip(vOrigMetricses, defaultYOrigs)) - else: - vOrigMetricses = None - - metricsStore, advanceMapping, vOrigMapping = _get_advance_metrics(font, - masterModel, master_ttfs, axisTags, glyphOrder, advMetricses, - vOrigMetricses) - - vhvar.VarStore = metricsStore - if advanceMapping is None: - setattr(vhvar, tableFields.advMapping, None) - else: - setattr(vhvar, tableFields.advMapping, advanceMapping) - if vOrigMapping is not None: - setattr(vhvar, tableFields.vOrigMapping, vOrigMapping) - setattr(vhvar, tableFields.sb1, None) - setattr(vhvar, tableFields.sb2, None) - - font[tableTag] = VHVAR - return - -def _get_advance_metrics(font, masterModel, master_ttfs, - axisTags, glyphOrder, advMetricses, vOrigMetricses=None): - - vhAdvanceDeltasAndSupports = {} - vOrigDeltasAndSupports = {} - for glyph in glyphOrder: - vhAdvances = [metrics[glyph][0] if glyph in metrics else None for metrics in advMetricses] - vhAdvanceDeltasAndSupports[glyph] = masterModel.getDeltasAndSupports(vhAdvances, round=round) - - singleModel = models.allEqual(id(v[1]) for v in vhAdvanceDeltasAndSupports.values()) - - if vOrigMetricses: - singleModel = False - for glyph in glyphOrder: - # We need to supply a vOrigs tuple with non-None default values - # for each glyph. vOrigMetricses contains values only for those - # glyphs which have a non-default vOrig. - vOrigs = [metrics[glyph] if glyph in metrics else defaultVOrig - for metrics, defaultVOrig in vOrigMetricses] - vOrigDeltasAndSupports[glyph] = masterModel.getDeltasAndSupports(vOrigs, round=round) - - directStore = None - if singleModel: - # Build direct mapping - supports = next(iter(vhAdvanceDeltasAndSupports.values()))[1][1:] - varTupleList = builder.buildVarRegionList(supports, axisTags) - varTupleIndexes = list(range(len(supports))) - varData = builder.buildVarData(varTupleIndexes, [], optimize=False) - for glyphName in glyphOrder: - varData.addItem(vhAdvanceDeltasAndSupports[glyphName][0], round=noRound) - varData.optimize() - directStore = builder.buildVarStore(varTupleList, [varData]) - - # Build optimized indirect mapping - storeBuilder = varStore.OnlineVarStoreBuilder(axisTags) - advMapping = {} - for glyphName in glyphOrder: - deltas, supports = vhAdvanceDeltasAndSupports[glyphName] - storeBuilder.setSupports(supports) - advMapping[glyphName] = storeBuilder.storeDeltas(deltas, round=noRound) - - if vOrigMetricses: - vOrigMap = {} - for glyphName in glyphOrder: - deltas, supports = vOrigDeltasAndSupports[glyphName] - storeBuilder.setSupports(supports) - vOrigMap[glyphName] = storeBuilder.storeDeltas(deltas, round=noRound) - - indirectStore = storeBuilder.finish() - mapping2 = indirectStore.optimize(use_NO_VARIATION_INDEX=False) - advMapping = [mapping2[advMapping[g]] for g in glyphOrder] - advanceMapping = builder.buildVarIdxMap(advMapping, glyphOrder) - - if vOrigMetricses: - vOrigMap = [mapping2[vOrigMap[g]] for g in glyphOrder] - - useDirect = False - vOrigMapping = None - if directStore: - # Compile both, see which is more compact - - writer = OTTableWriter() - directStore.compile(writer, font) - directSize = len(writer.getAllData()) - - writer = OTTableWriter() - indirectStore.compile(writer, font) - advanceMapping.compile(writer, font) - indirectSize = len(writer.getAllData()) - - useDirect = directSize < indirectSize - - if useDirect: - metricsStore = directStore - advanceMapping = None - else: - metricsStore = indirectStore - if vOrigMetricses: - vOrigMapping = builder.buildVarIdxMap(vOrigMap, glyphOrder) - - return metricsStore, advanceMapping, vOrigMapping def _add_MVAR(font, masterModel, master_ttfs, axisTags): - - log.info("Generating MVAR") - - store_builder = varStore.OnlineVarStoreBuilder(axisTags) - - records = [] - lastTableTag = None - fontTable = None - tables = None - # HACK: we need to special-case post.underlineThickness and .underlinePosition - # and unilaterally/arbitrarily define a sentinel value to distinguish the case - # when a post table is present in a given master simply because that's where - # the glyph names in TrueType must be stored, but the underline values are not - # meant to be used for building MVAR's deltas. The value of -0x8000 (-36768) - # the minimum FWord (int16) value, was chosen for its unlikelyhood to appear - # in real-world underline position/thickness values. - specialTags = {"unds": -0x8000, "undo": -0x8000} - - for tag, (tableTag, itemName) in sorted(MVAR_ENTRIES.items(), key=lambda kv: kv[1]): - # For each tag, fetch the associated table from all fonts (or not when we are - # still looking at a tag from the same tables) and set up the variation model - # for them. - if tableTag != lastTableTag: - tables = fontTable = None - if tableTag in font: - fontTable = font[tableTag] - tables = [] - for master in master_ttfs: - if tableTag not in master or ( - tag in specialTags - and getattr(master[tableTag], itemName) == specialTags[tag] - ): - tables.append(None) - else: - tables.append(master[tableTag]) - model, tables = masterModel.getSubModel(tables) - store_builder.setModel(model) - lastTableTag = tableTag - - if tables is None: # Tag not applicable to the master font. - continue - - # TODO support gasp entries - - master_values = [getattr(table, itemName) for table in tables] - if models.allEqual(master_values): - base, varIdx = master_values[0], None - else: - base, varIdx = store_builder.storeMasters(master_values) - setattr(fontTable, itemName, base) - - if varIdx is None: - continue - log.info(' %s: %s.%s %s', tag, tableTag, itemName, master_values) - rec = ot.MetricsValueRecord() - rec.ValueTag = tag - rec.VarIdx = varIdx - records.append(rec) - - assert "MVAR" not in font - if records: - store = store_builder.finish() - # Optimize - mapping = store.optimize() - for rec in records: - rec.VarIdx = mapping[rec.VarIdx] - - MVAR = font["MVAR"] = newTable('MVAR') - mvar = MVAR.table = ot.MVAR() - mvar.Version = 0x00010000 - mvar.Reserved = 0 - mvar.VarStore = store - # XXX these should not be hard-coded but computed automatically - mvar.ValueRecordSize = 8 - mvar.ValueRecordCount = len(records) - mvar.ValueRecord = sorted(records, key=lambda r: r.ValueTag) + log.info("Generating MVAR") + + store_builder = varStore.OnlineVarStoreBuilder(axisTags) + + records = [] + lastTableTag = None + fontTable = None + tables = None + # HACK: we need to special-case post.underlineThickness and .underlinePosition + # and unilaterally/arbitrarily define a sentinel value to distinguish the case + # when a post table is present in a given master simply because that's where + # the glyph names in TrueType must be stored, but the underline values are not + # meant to be used for building MVAR's deltas. The value of -0x8000 (-36768) + # the minimum FWord (int16) value, was chosen for its unlikelyhood to appear + # in real-world underline position/thickness values. + specialTags = {"unds": -0x8000, "undo": -0x8000} + + for tag, (tableTag, itemName) in sorted(MVAR_ENTRIES.items(), key=lambda kv: kv[1]): + # For each tag, fetch the associated table from all fonts (or not when we are + # still looking at a tag from the same tables) and set up the variation model + # for them. + if tableTag != lastTableTag: + tables = fontTable = None + if tableTag in font: + fontTable = font[tableTag] + tables = [] + for master in master_ttfs: + if tableTag not in master or ( + tag in specialTags + and getattr(master[tableTag], itemName) == specialTags[tag] + ): + tables.append(None) + else: + tables.append(master[tableTag]) + model, tables = masterModel.getSubModel(tables) + store_builder.setModel(model) + lastTableTag = tableTag + + if tables is None: # Tag not applicable to the master font. + continue + + # TODO support gasp entries + + master_values = [getattr(table, itemName) for table in tables] + if models.allEqual(master_values): + base, varIdx = master_values[0], None + else: + base, varIdx = store_builder.storeMasters(master_values) + setattr(fontTable, itemName, base) + + if varIdx is None: + continue + log.info(" %s: %s.%s %s", tag, tableTag, itemName, master_values) + rec = ot.MetricsValueRecord() + rec.ValueTag = tag + rec.VarIdx = varIdx + records.append(rec) + + assert "MVAR" not in font + if records: + store = store_builder.finish() + # Optimize + mapping = store.optimize() + for rec in records: + rec.VarIdx = mapping[rec.VarIdx] + + MVAR = font["MVAR"] = newTable("MVAR") + mvar = MVAR.table = ot.MVAR() + mvar.Version = 0x00010000 + mvar.Reserved = 0 + mvar.VarStore = store + # XXX these should not be hard-coded but computed automatically + mvar.ValueRecordSize = 8 + mvar.ValueRecordCount = len(records) + mvar.ValueRecord = sorted(records, key=lambda r: r.ValueTag) def _add_BASE(font, masterModel, master_ttfs, axisTags): + log.info("Generating BASE") - log.info("Generating BASE") + merger = VariationMerger(masterModel, axisTags, font) + merger.mergeTables(font, master_ttfs, ["BASE"]) + store = merger.store_builder.finish() - merger = VariationMerger(masterModel, axisTags, font) - merger.mergeTables(font, master_ttfs, ['BASE']) - store = merger.store_builder.finish() - - if not store: - return - base = font['BASE'].table - assert base.Version == 0x00010000 - base.Version = 0x00010001 - base.VarStore = store + if not store: + return + base = font["BASE"].table + assert base.Version == 0x00010000 + base.Version = 0x00010001 + base.VarStore = store def _merge_OTL(font, model, master_fonts, axisTags): - - log.info("Merging OpenType Layout tables") - merger = VariationMerger(model, axisTags, font) - - merger.mergeTables(font, master_fonts, ['GSUB', 'GDEF', 'GPOS']) - store = merger.store_builder.finish() - if not store: - return - try: - GDEF = font['GDEF'].table - assert GDEF.Version <= 0x00010002 - except KeyError: - font['GDEF'] = newTable('GDEF') - GDEFTable = font["GDEF"] = newTable('GDEF') - GDEF = GDEFTable.table = ot.GDEF() - GDEF.GlyphClassDef = None - GDEF.AttachList = None - GDEF.LigCaretList = None - GDEF.MarkAttachClassDef = None - GDEF.MarkGlyphSetsDef = None - - GDEF.Version = 0x00010003 - GDEF.VarStore = store - - # Optimize - varidx_map = store.optimize() - GDEF.remap_device_varidxes(varidx_map) - if 'GPOS' in font: - font['GPOS'].table.remap_device_varidxes(varidx_map) + log.info("Merging OpenType Layout tables") + merger = VariationMerger(model, axisTags, font) + + merger.mergeTables(font, master_fonts, ["GSUB", "GDEF", "GPOS"]) + store = merger.store_builder.finish() + if not store: + return + try: + GDEF = font["GDEF"].table + assert GDEF.Version <= 0x00010002 + except KeyError: + font["GDEF"] = newTable("GDEF") + GDEFTable = font["GDEF"] = newTable("GDEF") + GDEF = GDEFTable.table = ot.GDEF() + GDEF.GlyphClassDef = None + GDEF.AttachList = None + GDEF.LigCaretList = None + GDEF.MarkAttachClassDef = None + GDEF.MarkGlyphSetsDef = None + + GDEF.Version = 0x00010003 + GDEF.VarStore = store + + # Optimize + varidx_map = store.optimize() + GDEF.remap_device_varidxes(varidx_map) + if "GPOS" in font: + font["GPOS"].table.remap_device_varidxes(varidx_map) def _add_GSUB_feature_variations(font, axes, internal_axis_supports, rules, featureTag): + def normalize(name, value): + return models.normalizeLocation({name: value}, internal_axis_supports)[name] - def normalize(name, value): - return models.normalizeLocation( - {name: value}, internal_axis_supports - )[name] + log.info("Generating GSUB FeatureVariations") - log.info("Generating GSUB FeatureVariations") + axis_tags = {name: axis.tag for name, axis in axes.items()} - axis_tags = {name: axis.tag for name, axis in axes.items()} + conditional_subs = [] + for rule in rules: + region = [] + for conditions in rule.conditionSets: + space = {} + for condition in conditions: + axis_name = condition["name"] + if condition["minimum"] is not None: + minimum = normalize(axis_name, condition["minimum"]) + else: + minimum = -1.0 + if condition["maximum"] is not None: + maximum = normalize(axis_name, condition["maximum"]) + else: + maximum = 1.0 + tag = axis_tags[axis_name] + space[tag] = (minimum, maximum) + region.append(space) - conditional_subs = [] - for rule in rules: + subs = {k: v for k, v in rule.subs} - region = [] - for conditions in rule.conditionSets: - space = {} - for condition in conditions: - axis_name = condition["name"] - if condition["minimum"] is not None: - minimum = normalize(axis_name, condition["minimum"]) - else: - minimum = -1.0 - if condition["maximum"] is not None: - maximum = normalize(axis_name, condition["maximum"]) - else: - maximum = 1.0 - tag = axis_tags[axis_name] - space[tag] = (minimum, maximum) - region.append(space) + conditional_subs.append((region, subs)) - subs = {k: v for k, v in rule.subs} - - conditional_subs.append((region, subs)) - - addFeatureVariations(font, conditional_subs, featureTag) + addFeatureVariations(font, conditional_subs, featureTag) _DesignSpaceData = namedtuple( - "_DesignSpaceData", - [ - "axes", - "internal_axis_supports", - "base_idx", - "normalized_master_locs", - "masters", - "instances", - "rules", - "rulesProcessingLast", - "lib", - ], + "_DesignSpaceData", + [ + "axes", + "axisMappings", + "internal_axis_supports", + "base_idx", + "normalized_master_locs", + "masters", + "instances", + "rules", + "rulesProcessingLast", + "lib", + ], ) def _add_CFF2(varFont, model, master_fonts): - from .cff import merge_region_fonts - glyphOrder = varFont.getGlyphOrder() - if "CFF2" not in varFont: - from .cff import convertCFFtoCFF2 - convertCFFtoCFF2(varFont) - ordered_fonts_list = model.reorderMasters(master_fonts, model.reverseMapping) - # re-ordering the master list simplifies building the CFF2 data item lists. - merge_region_fonts(varFont, model, ordered_fonts_list, glyphOrder) + from .cff import merge_region_fonts + + glyphOrder = varFont.getGlyphOrder() + if "CFF2" not in varFont: + from .cff import convertCFFtoCFF2 + + convertCFFtoCFF2(varFont) + ordered_fonts_list = model.reorderMasters(master_fonts, model.reverseMapping) + # re-ordering the master list simplifies building the CFF2 data item lists. + merge_region_fonts(varFont, model, ordered_fonts_list, glyphOrder) def _add_COLR(font, model, master_fonts, axisTags, colr_layer_reuse=True): - merger = COLRVariationMerger(model, axisTags, font, allowLayerReuse=colr_layer_reuse) - merger.mergeTables(font, master_fonts) - store = merger.store_builder.finish() + merger = COLRVariationMerger( + model, axisTags, font, allowLayerReuse=colr_layer_reuse + ) + merger.mergeTables(font, master_fonts) + store = merger.store_builder.finish() - colr = font["COLR"].table - if store: - mapping = store.optimize() - colr.VarStore = store - varIdxes = [mapping[v] for v in merger.varIdxes] - colr.VarIndexMap = builder.buildDeltaSetIndexMap(varIdxes) + colr = font["COLR"].table + if store: + mapping = store.optimize() + colr.VarStore = store + varIdxes = [mapping[v] for v in merger.varIdxes] + colr.VarIndexMap = builder.buildDeltaSetIndexMap(varIdxes) def load_designspace(designspace): - # TODO: remove this and always assume 'designspace' is a DesignSpaceDocument, - # never a file path, as that's already handled by caller - if hasattr(designspace, "sources"): # Assume a DesignspaceDocument - ds = designspace - else: # Assume a file path - ds = DesignSpaceDocument.fromfile(designspace) - - masters = ds.sources - if not masters: - raise VarLibValidationError("Designspace must have at least one source.") - instances = ds.instances - - # TODO: Use fontTools.designspaceLib.tagForAxisName instead. - standard_axis_map = OrderedDict([ - ('weight', ('wght', {'en': u'Weight'})), - ('width', ('wdth', {'en': u'Width'})), - ('slant', ('slnt', {'en': u'Slant'})), - ('optical', ('opsz', {'en': u'Optical Size'})), - ('italic', ('ital', {'en': u'Italic'})), - ]) - - # Setup axes - if not ds.axes: - raise VarLibValidationError(f"Designspace must have at least one axis.") - - axes = OrderedDict() - for axis_index, axis in enumerate(ds.axes): - axis_name = axis.name - if not axis_name: - if not axis.tag: - raise VarLibValidationError(f"Axis at index {axis_index} needs a tag.") - axis_name = axis.name = axis.tag - - if axis_name in standard_axis_map: - if axis.tag is None: - axis.tag = standard_axis_map[axis_name][0] - if not axis.labelNames: - axis.labelNames.update(standard_axis_map[axis_name][1]) - else: - if not axis.tag: - raise VarLibValidationError(f"Axis at index {axis_index} needs a tag.") - if not axis.labelNames: - axis.labelNames["en"] = tostr(axis_name) - - axes[axis_name] = axis - log.info("Axes:\n%s", pformat([axis.asdict() for axis in axes.values()])) - - # Check all master and instance locations are valid and fill in defaults - for obj in masters+instances: - obj_name = obj.name or obj.styleName or '' - loc = obj.getFullDesignLocation(ds) - obj.designLocation = loc - if loc is None: - raise VarLibValidationError( - f"Source or instance '{obj_name}' has no location." - ) - for axis_name in loc.keys(): - if axis_name not in axes: - raise VarLibValidationError( - f"Location axis '{axis_name}' unknown for '{obj_name}'." - ) - for axis_name,axis in axes.items(): - v = axis.map_backward(loc[axis_name]) - if not (axis.minimum <= v <= axis.maximum): - raise VarLibValidationError( - f"Source or instance '{obj_name}' has out-of-range location " - f"for axis '{axis_name}': is mapped to {v} but must be in " - f"mapped range [{axis.minimum}..{axis.maximum}] (NOTE: all " - "values are in user-space)." - ) - - # Normalize master locations - - internal_master_locs = [o.getFullDesignLocation(ds) for o in masters] - log.info("Internal master locations:\n%s", pformat(internal_master_locs)) - - # TODO This mapping should ideally be moved closer to logic in _add_fvar/avar - internal_axis_supports = {} - for axis in axes.values(): - triple = (axis.minimum, axis.default, axis.maximum) - internal_axis_supports[axis.name] = [axis.map_forward(v) for v in triple] - log.info("Internal axis supports:\n%s", pformat(internal_axis_supports)) - - normalized_master_locs = [models.normalizeLocation(m, internal_axis_supports) for m in internal_master_locs] - log.info("Normalized master locations:\n%s", pformat(normalized_master_locs)) - - # Find base master - base_idx = None - for i,m in enumerate(normalized_master_locs): - if all(v == 0 for v in m.values()): - if base_idx is not None: - raise VarLibValidationError( - "More than one base master found in Designspace." - ) - base_idx = i - if base_idx is None: - raise VarLibValidationError( - "Base master not found; no master at default location?" - ) - log.info("Index of base master: %s", base_idx) - - return _DesignSpaceData( - axes, - internal_axis_supports, - base_idx, - normalized_master_locs, - masters, - instances, - ds.rules, - ds.rulesProcessingLast, - ds.lib, - ) + # TODO: remove this and always assume 'designspace' is a DesignSpaceDocument, + # never a file path, as that's already handled by caller + if hasattr(designspace, "sources"): # Assume a DesignspaceDocument + ds = designspace + else: # Assume a file path + ds = DesignSpaceDocument.fromfile(designspace) + + masters = ds.sources + if not masters: + raise VarLibValidationError("Designspace must have at least one source.") + instances = ds.instances + + # TODO: Use fontTools.designspaceLib.tagForAxisName instead. + standard_axis_map = OrderedDict( + [ + ("weight", ("wght", {"en": "Weight"})), + ("width", ("wdth", {"en": "Width"})), + ("slant", ("slnt", {"en": "Slant"})), + ("optical", ("opsz", {"en": "Optical Size"})), + ("italic", ("ital", {"en": "Italic"})), + ] + ) + + # Setup axes + if not ds.axes: + raise VarLibValidationError(f"Designspace must have at least one axis.") + + axes = OrderedDict() + for axis_index, axis in enumerate(ds.axes): + axis_name = axis.name + if not axis_name: + if not axis.tag: + raise VarLibValidationError(f"Axis at index {axis_index} needs a tag.") + axis_name = axis.name = axis.tag + + if axis_name in standard_axis_map: + if axis.tag is None: + axis.tag = standard_axis_map[axis_name][0] + if not axis.labelNames: + axis.labelNames.update(standard_axis_map[axis_name][1]) + else: + if not axis.tag: + raise VarLibValidationError(f"Axis at index {axis_index} needs a tag.") + if not axis.labelNames: + axis.labelNames["en"] = tostr(axis_name) + + axes[axis_name] = axis + log.info("Axes:\n%s", pformat([axis.asdict() for axis in axes.values()])) + + axisMappings = ds.axisMappings + if axisMappings: + log.info("Mappings:\n%s", pformat(axisMappings)) + + # Check all master and instance locations are valid and fill in defaults + for obj in masters + instances: + obj_name = obj.name or obj.styleName or "" + loc = obj.getFullDesignLocation(ds) + obj.designLocation = loc + if loc is None: + raise VarLibValidationError( + f"Source or instance '{obj_name}' has no location." + ) + for axis_name in loc.keys(): + if axis_name not in axes: + raise VarLibValidationError( + f"Location axis '{axis_name}' unknown for '{obj_name}'." + ) + for axis_name, axis in axes.items(): + v = axis.map_backward(loc[axis_name]) + if not (axis.minimum <= v <= axis.maximum): + raise VarLibValidationError( + f"Source or instance '{obj_name}' has out-of-range location " + f"for axis '{axis_name}': is mapped to {v} but must be in " + f"mapped range [{axis.minimum}..{axis.maximum}] (NOTE: all " + "values are in user-space)." + ) + + # Normalize master locations + + internal_master_locs = [o.getFullDesignLocation(ds) for o in masters] + log.info("Internal master locations:\n%s", pformat(internal_master_locs)) + + # TODO This mapping should ideally be moved closer to logic in _add_fvar/avar + internal_axis_supports = {} + for axis in axes.values(): + triple = (axis.minimum, axis.default, axis.maximum) + internal_axis_supports[axis.name] = [axis.map_forward(v) for v in triple] + log.info("Internal axis supports:\n%s", pformat(internal_axis_supports)) + + normalized_master_locs = [ + models.normalizeLocation(m, internal_axis_supports) + for m in internal_master_locs + ] + log.info("Normalized master locations:\n%s", pformat(normalized_master_locs)) + + # Find base master + base_idx = None + for i, m in enumerate(normalized_master_locs): + if all(v == 0 for v in m.values()): + if base_idx is not None: + raise VarLibValidationError( + "More than one base master found in Designspace." + ) + base_idx = i + if base_idx is None: + raise VarLibValidationError( + "Base master not found; no master at default location?" + ) + log.info("Index of base master: %s", base_idx) + + return _DesignSpaceData( + axes, + axisMappings, + internal_axis_supports, + base_idx, + normalized_master_locs, + masters, + instances, + ds.rules, + ds.rulesProcessingLast, + ds.lib, + ) # https://docs.microsoft.com/en-us/typography/opentype/spec/os2#uswidthclass WDTH_VALUE_TO_OS2_WIDTH_CLASS = { - 50: 1, - 62.5: 2, - 75: 3, - 87.5: 4, - 100: 5, - 112.5: 6, - 125: 7, - 150: 8, - 200: 9, + 50: 1, + 62.5: 2, + 75: 3, + 87.5: 4, + 100: 5, + 112.5: 6, + 125: 7, + 150: 8, + 200: 9, } def set_default_weight_width_slant(font, location): - if "OS/2" in font: - if "wght" in location: - weight_class = otRound(max(1, min(location["wght"], 1000))) - if font["OS/2"].usWeightClass != weight_class: - log.info("Setting OS/2.usWeightClass = %s", weight_class) - font["OS/2"].usWeightClass = weight_class - - if "wdth" in location: - # map 'wdth' axis (50..200) to OS/2.usWidthClass (1..9), rounding to closest - widthValue = min(max(location["wdth"], 50), 200) - widthClass = otRound( - models.piecewiseLinearMap(widthValue, WDTH_VALUE_TO_OS2_WIDTH_CLASS) - ) - if font["OS/2"].usWidthClass != widthClass: - log.info("Setting OS/2.usWidthClass = %s", widthClass) - font["OS/2"].usWidthClass = widthClass - - if "slnt" in location and "post" in font: - italicAngle = max(-90, min(location["slnt"], 90)) - if font["post"].italicAngle != italicAngle: - log.info("Setting post.italicAngle = %s", italicAngle) - font["post"].italicAngle = italicAngle + if "OS/2" in font: + if "wght" in location: + weight_class = otRound(max(1, min(location["wght"], 1000))) + if font["OS/2"].usWeightClass != weight_class: + log.info("Setting OS/2.usWeightClass = %s", weight_class) + font["OS/2"].usWeightClass = weight_class + + if "wdth" in location: + # map 'wdth' axis (50..200) to OS/2.usWidthClass (1..9), rounding to closest + widthValue = min(max(location["wdth"], 50), 200) + widthClass = otRound( + models.piecewiseLinearMap(widthValue, WDTH_VALUE_TO_OS2_WIDTH_CLASS) + ) + if font["OS/2"].usWidthClass != widthClass: + log.info("Setting OS/2.usWidthClass = %s", widthClass) + font["OS/2"].usWidthClass = widthClass + + if "slnt" in location and "post" in font: + italicAngle = max(-90, min(location["slnt"], 90)) + if font["post"].italicAngle != italicAngle: + log.info("Setting post.italicAngle = %s", italicAngle) + font["post"].italicAngle = italicAngle + + +def drop_implied_oncurve_points(*masters: TTFont) -> int: + """Drop impliable on-curve points from all the simple glyphs in masters. + + In TrueType glyf outlines, on-curve points can be implied when they are located + exactly at the midpoint of the line connecting two consecutive off-curve points. + + The input masters' glyf tables are assumed to contain same-named glyphs that are + interpolatable. Oncurve points are only dropped if they can be implied for all + the masters. The fonts are modified in-place. + + Args: + masters: The TTFont(s) to modify + + Returns: + The total number of points that were dropped if any. + + Reference: + https://developer.apple.com/fonts/TrueType-Reference-Manual/RM01/Chap1.html + """ + + count = 0 + glyph_masters = defaultdict(list) + # multiple DS source may point to the same TTFont object and we want to + # avoid processing the same glyph twice as they are modified in-place + for font in {id(m): m for m in masters}.values(): + glyf = font["glyf"] + for glyphName in glyf.keys(): + glyph_masters[glyphName].append(glyf[glyphName]) + count = 0 + for glyphName, glyphs in glyph_masters.items(): + try: + dropped = dropImpliedOnCurvePoints(*glyphs) + except ValueError as e: + # we don't fail for incompatible glyphs in _add_gvar so we shouldn't here + log.warning("Failed to drop implied oncurves for %r: %s", glyphName, e) + else: + count += len(dropped) + return count def build_many( - designspace: DesignSpaceDocument, - master_finder=lambda s:s, - exclude=[], - optimize=True, - skip_vf=lambda vf_name: False, - colr_layer_reuse=True, + designspace: DesignSpaceDocument, + master_finder=lambda s: s, + exclude=[], + optimize=True, + skip_vf=lambda vf_name: False, + colr_layer_reuse=True, + drop_implied_oncurves=False, ): - """ - Build variable fonts from a designspace file, version 5 which can define - several VFs, or version 4 which has implicitly one VF covering the whole doc. - - If master_finder is set, it should be a callable that takes master - filename as found in designspace file and map it to master font - binary as to be opened (eg. .ttf or .otf). - - skip_vf can be used to skip building some of the variable fonts defined in - the input designspace. It's a predicate that takes as argument the name - of the variable font and returns `bool`. - - Always returns a Dict[str, TTFont] keyed by VariableFontDescriptor.name - """ - res = {} - for _location, subDoc in splitInterpolable(designspace): - for name, vfDoc in splitVariableFonts(subDoc): - if skip_vf(name): - log.debug(f"Skipping variable TTF font: {name}") - continue - vf = build( - vfDoc, - master_finder, - exclude=list(exclude) + ["STAT"], - optimize=optimize, - colr_layer_reuse=colr_layer_reuse, - )[0] - if "STAT" not in exclude: - buildVFStatTable(vf, designspace, name) - res[name] = vf - return res + """ + Build variable fonts from a designspace file, version 5 which can define + several VFs, or version 4 which has implicitly one VF covering the whole doc. + + If master_finder is set, it should be a callable that takes master + filename as found in designspace file and map it to master font + binary as to be opened (eg. .ttf or .otf). + + skip_vf can be used to skip building some of the variable fonts defined in + the input designspace. It's a predicate that takes as argument the name + of the variable font and returns `bool`. + + Always returns a Dict[str, TTFont] keyed by VariableFontDescriptor.name + """ + res = {} + # varLib.build (used further below) by default only builds an incomplete 'STAT' + # with an empty AxisValueArray--unless the VF inherited 'STAT' from its base master. + # Designspace version 5 can also be used to define 'STAT' labels or customize + # axes ordering, etc. To avoid overwriting a pre-existing 'STAT' or redoing the + # same work twice, here we check if designspace contains any 'STAT' info before + # proceeding to call buildVFStatTable for each VF. + # https://github.com/fonttools/fonttools/pull/3024 + # https://github.com/fonttools/fonttools/issues/3045 + doBuildStatFromDSv5 = ( + "STAT" not in exclude + and designspace.formatTuple >= (5, 0) + and ( + any(a.axisLabels or a.axisOrdering is not None for a in designspace.axes) + or designspace.locationLabels + ) + ) + for _location, subDoc in splitInterpolable(designspace): + for name, vfDoc in splitVariableFonts(subDoc): + if skip_vf(name): + log.debug(f"Skipping variable TTF font: {name}") + continue + vf = build( + vfDoc, + master_finder, + exclude=exclude, + optimize=optimize, + colr_layer_reuse=colr_layer_reuse, + drop_implied_oncurves=drop_implied_oncurves, + )[0] + if doBuildStatFromDSv5: + buildVFStatTable(vf, designspace, name) + res[name] = vf + return res + def build( - designspace, - master_finder=lambda s:s, - exclude=[], - optimize=True, - colr_layer_reuse=True, + designspace, + master_finder=lambda s: s, + exclude=[], + optimize=True, + colr_layer_reuse=True, + drop_implied_oncurves=False, ): - """ - Build variation font from a designspace file. - - If master_finder is set, it should be a callable that takes master - filename as found in designspace file and map it to master font - binary as to be opened (eg. .ttf or .otf). - """ - if hasattr(designspace, "sources"): # Assume a DesignspaceDocument - pass - else: # Assume a file path - designspace = DesignSpaceDocument.fromfile(designspace) - - ds = load_designspace(designspace) - log.info("Building variable font") - - log.info("Loading master fonts") - master_fonts = load_masters(designspace, master_finder) - - # TODO: 'master_ttfs' is unused except for return value, remove later - master_ttfs = [] - for master in master_fonts: - try: - master_ttfs.append(master.reader.file.name) - except AttributeError: - master_ttfs.append(None) # in-memory fonts have no path - - # Copy the base master to work from it - vf = deepcopy(master_fonts[ds.base_idx]) - - # TODO append masters as named-instances as well; needs .designspace change. - fvar = _add_fvar(vf, ds.axes, ds.instances) - if 'STAT' not in exclude: - _add_stat(vf) - if 'avar' not in exclude: - _add_avar(vf, ds.axes) - - # Map from axis names to axis tags... - normalized_master_locs = [ - {ds.axes[k].tag: v for k,v in loc.items()} for loc in ds.normalized_master_locs - ] - # From here on, we use fvar axes only - axisTags = [axis.axisTag for axis in fvar.axes] - - # Assume single-model for now. - model = models.VariationModel(normalized_master_locs, axisOrder=axisTags) - assert 0 == model.mapping[ds.base_idx] - - log.info("Building variations tables") - if 'BASE' not in exclude and 'BASE' in vf: - _add_BASE(vf, model, master_fonts, axisTags) - if 'MVAR' not in exclude: - _add_MVAR(vf, model, master_fonts, axisTags) - if 'HVAR' not in exclude: - _add_HVAR(vf, model, master_fonts, axisTags) - if 'VVAR' not in exclude and 'vmtx' in vf: - _add_VVAR(vf, model, master_fonts, axisTags) - if 'GDEF' not in exclude or 'GPOS' not in exclude: - _merge_OTL(vf, model, master_fonts, axisTags) - if 'gvar' not in exclude and 'glyf' in vf: - _add_gvar(vf, model, master_fonts, optimize=optimize) - if 'cvar' not in exclude and 'glyf' in vf: - _merge_TTHinting(vf, model, master_fonts) - if 'GSUB' not in exclude and ds.rules: - featureTag = ds.lib.get( - FEAVAR_FEATURETAG_LIB_KEY, - "rclt" if ds.rulesProcessingLast else "rvrn" - ) - _add_GSUB_feature_variations(vf, ds.axes, ds.internal_axis_supports, ds.rules, featureTag) - if 'CFF2' not in exclude and ('CFF ' in vf or 'CFF2' in vf): - _add_CFF2(vf, model, master_fonts) - if "post" in vf: - # set 'post' to format 2 to keep the glyph names dropped from CFF2 - post = vf["post"] - if post.formatType != 2.0: - post.formatType = 2.0 - post.extraNames = [] - post.mapping = {} - if 'COLR' not in exclude and 'COLR' in vf and vf['COLR'].version > 0: - _add_COLR(vf, model, master_fonts, axisTags, colr_layer_reuse) - - set_default_weight_width_slant( - vf, location={axis.axisTag: axis.defaultValue for axis in vf["fvar"].axes} - ) - - for tag in exclude: - if tag in vf: - del vf[tag] - - # TODO: Only return vf for 4.0+, the rest is unused. - return vf, model, master_ttfs + """ + Build variation font from a designspace file. + + If master_finder is set, it should be a callable that takes master + filename as found in designspace file and map it to master font + binary as to be opened (eg. .ttf or .otf). + """ + if hasattr(designspace, "sources"): # Assume a DesignspaceDocument + pass + else: # Assume a file path + designspace = DesignSpaceDocument.fromfile(designspace) + + ds = load_designspace(designspace) + log.info("Building variable font") + + log.info("Loading master fonts") + master_fonts = load_masters(designspace, master_finder) + + # TODO: 'master_ttfs' is unused except for return value, remove later + master_ttfs = [] + for master in master_fonts: + try: + master_ttfs.append(master.reader.file.name) + except AttributeError: + master_ttfs.append(None) # in-memory fonts have no path + + if drop_implied_oncurves and "glyf" in master_fonts[ds.base_idx]: + drop_count = drop_implied_oncurve_points(*master_fonts) + log.info( + "Dropped %s on-curve points from simple glyphs in the 'glyf' table", + drop_count, + ) + + # Copy the base master to work from it + vf = deepcopy(master_fonts[ds.base_idx]) + + if "DSIG" in vf: + del vf["DSIG"] + + # TODO append masters as named-instances as well; needs .designspace change. + fvar = _add_fvar(vf, ds.axes, ds.instances) + if "STAT" not in exclude: + _add_stat(vf) + + # Map from axis names to axis tags... + normalized_master_locs = [ + {ds.axes[k].tag: v for k, v in loc.items()} for loc in ds.normalized_master_locs + ] + # From here on, we use fvar axes only + axisTags = [axis.axisTag for axis in fvar.axes] + + # Assume single-model for now. + model = models.VariationModel(normalized_master_locs, axisOrder=axisTags) + assert 0 == model.mapping[ds.base_idx] + + log.info("Building variations tables") + if "avar" not in exclude: + _add_avar(vf, ds.axes, ds.axisMappings, axisTags) + if "BASE" not in exclude and "BASE" in vf: + _add_BASE(vf, model, master_fonts, axisTags) + if "MVAR" not in exclude: + _add_MVAR(vf, model, master_fonts, axisTags) + if "HVAR" not in exclude: + _add_HVAR(vf, model, master_fonts, axisTags) + if "VVAR" not in exclude and "vmtx" in vf: + _add_VVAR(vf, model, master_fonts, axisTags) + if "GDEF" not in exclude or "GPOS" not in exclude: + _merge_OTL(vf, model, master_fonts, axisTags) + if "gvar" not in exclude and "glyf" in vf: + _add_gvar(vf, model, master_fonts, optimize=optimize) + if "cvar" not in exclude and "glyf" in vf: + _merge_TTHinting(vf, model, master_fonts) + if "GSUB" not in exclude and ds.rules: + featureTag = ds.lib.get( + FEAVAR_FEATURETAG_LIB_KEY, "rclt" if ds.rulesProcessingLast else "rvrn" + ) + _add_GSUB_feature_variations( + vf, ds.axes, ds.internal_axis_supports, ds.rules, featureTag + ) + if "CFF2" not in exclude and ("CFF " in vf or "CFF2" in vf): + _add_CFF2(vf, model, master_fonts) + if "post" in vf: + # set 'post' to format 2 to keep the glyph names dropped from CFF2 + post = vf["post"] + if post.formatType != 2.0: + post.formatType = 2.0 + post.extraNames = [] + post.mapping = {} + if "COLR" not in exclude and "COLR" in vf and vf["COLR"].version > 0: + _add_COLR(vf, model, master_fonts, axisTags, colr_layer_reuse) + + set_default_weight_width_slant( + vf, location={axis.axisTag: axis.defaultValue for axis in vf["fvar"].axes} + ) + + for tag in exclude: + if tag in vf: + del vf[tag] + + # TODO: Only return vf for 4.0+, the rest is unused. + return vf, model, master_ttfs def _open_font(path, master_finder=lambda s: s): - # load TTFont masters from given 'path': this can be either a .TTX or an - # OpenType binary font; or if neither of these, try use the 'master_finder' - # callable to resolve the path to a valid .TTX or OpenType font binary. - from fontTools.ttx import guessFileType - - master_path = os.path.normpath(path) - tp = guessFileType(master_path) - if tp is None: - # not an OpenType binary/ttx, fall back to the master finder. - master_path = master_finder(master_path) - tp = guessFileType(master_path) - if tp in ("TTX", "OTX"): - font = TTFont() - font.importXML(master_path) - elif tp in ("TTF", "OTF", "WOFF", "WOFF2"): - font = TTFont(master_path) - else: - raise VarLibValidationError("Invalid master path: %r" % master_path) - return font + # load TTFont masters from given 'path': this can be either a .TTX or an + # OpenType binary font; or if neither of these, try use the 'master_finder' + # callable to resolve the path to a valid .TTX or OpenType font binary. + from fontTools.ttx import guessFileType + + master_path = os.path.normpath(path) + tp = guessFileType(master_path) + if tp is None: + # not an OpenType binary/ttx, fall back to the master finder. + master_path = master_finder(master_path) + tp = guessFileType(master_path) + if tp in ("TTX", "OTX"): + font = TTFont() + font.importXML(master_path) + elif tp in ("TTF", "OTF", "WOFF", "WOFF2"): + font = TTFont(master_path) + else: + raise VarLibValidationError("Invalid master path: %r" % master_path) + return font def load_masters(designspace, master_finder=lambda s: s): - """Ensure that all SourceDescriptor.font attributes have an appropriate TTFont - object loaded, or else open TTFont objects from the SourceDescriptor.path - attributes. - - The paths can point to either an OpenType font, a TTX file, or a UFO. In the - latter case, use the provided master_finder callable to map from UFO paths to - the respective master font binaries (e.g. .ttf, .otf or .ttx). - - Return list of master TTFont objects in the same order they are listed in the - DesignSpaceDocument. - """ - for master in designspace.sources: - # If a SourceDescriptor has a layer name, demand that the compiled TTFont - # be supplied by the caller. This spares us from modifying MasterFinder. - if master.layerName and master.font is None: - raise VarLibValidationError( - f"Designspace source '{master.name or '<Unknown>'}' specified a " - "layer name but lacks the required TTFont object in the 'font' " - "attribute." - ) - - return designspace.loadSourceFonts(_open_font, master_finder=master_finder) + """Ensure that all SourceDescriptor.font attributes have an appropriate TTFont + object loaded, or else open TTFont objects from the SourceDescriptor.path + attributes. + + The paths can point to either an OpenType font, a TTX file, or a UFO. In the + latter case, use the provided master_finder callable to map from UFO paths to + the respective master font binaries (e.g. .ttf, .otf or .ttx). + + Return list of master TTFont objects in the same order they are listed in the + DesignSpaceDocument. + """ + for master in designspace.sources: + # If a SourceDescriptor has a layer name, demand that the compiled TTFont + # be supplied by the caller. This spares us from modifying MasterFinder. + if master.layerName and master.font is None: + raise VarLibValidationError( + f"Designspace source '{master.name or '<Unknown>'}' specified a " + "layer name but lacks the required TTFont object in the 'font' " + "attribute." + ) + + return designspace.loadSourceFonts(_open_font, master_finder=master_finder) class MasterFinder(object): - - def __init__(self, template): - self.template = template - - def __call__(self, src_path): - fullname = os.path.abspath(src_path) - dirname, basename = os.path.split(fullname) - stem, ext = os.path.splitext(basename) - path = self.template.format( - fullname=fullname, - dirname=dirname, - basename=basename, - stem=stem, - ext=ext, - ) - return os.path.normpath(path) + def __init__(self, template): + self.template = template + + def __call__(self, src_path): + fullname = os.path.abspath(src_path) + dirname, basename = os.path.split(fullname) + stem, ext = os.path.splitext(basename) + path = self.template.format( + fullname=fullname, + dirname=dirname, + basename=basename, + stem=stem, + ext=ext, + ) + return os.path.normpath(path) def main(args=None): - """Build a variable font from a designspace file and masters""" - from argparse import ArgumentParser - from fontTools import configLogger - - parser = ArgumentParser(prog='varLib', description = main.__doc__) - parser.add_argument('designspace') - parser.add_argument( - '-o', - metavar='OUTPUTFILE', - dest='outfile', - default=None, - help='output file' - ) - parser.add_argument( - '-x', - metavar='TAG', - dest='exclude', - action='append', - default=[], - help='exclude table' - ) - parser.add_argument( - '--disable-iup', - dest='optimize', - action='store_false', - help='do not perform IUP optimization' - ) - parser.add_argument( - '--no-colr-layer-reuse', - dest='colr_layer_reuse', - action='store_false', - help='do not rebuild variable COLR table to optimize COLR layer reuse', - ) - parser.add_argument( - '--master-finder', - default='master_ttf_interpolatable/{stem}.ttf', - help=( - 'templated string used for finding binary font ' - 'files given the source file names defined in the ' - 'designspace document. The following special strings ' - 'are defined: {fullname} is the absolute source file ' - 'name; {basename} is the file name without its ' - 'directory; {stem} is the basename without the file ' - 'extension; {ext} is the source file extension; ' - '{dirname} is the directory of the absolute file ' - 'name. The default value is "%(default)s".' - ) - ) - logging_group = parser.add_mutually_exclusive_group(required=False) - logging_group.add_argument( - "-v", "--verbose", - action="store_true", - help="Run more verbosely.") - logging_group.add_argument( - "-q", "--quiet", - action="store_true", - help="Turn verbosity off.") - options = parser.parse_args(args) - - configLogger(level=( - "DEBUG" if options.verbose else - "ERROR" if options.quiet else - "INFO")) - - designspace_filename = options.designspace - finder = MasterFinder(options.master_finder) - - vf, _, _ = build( - designspace_filename, - finder, - exclude=options.exclude, - optimize=options.optimize, - colr_layer_reuse=options.colr_layer_reuse, - ) - - outfile = options.outfile - if outfile is None: - ext = "otf" if vf.sfntVersion == "OTTO" else "ttf" - outfile = os.path.splitext(designspace_filename)[0] + '-VF.' + ext - - log.info("Saving variation font %s", outfile) - vf.save(outfile) + """Build variable fonts from a designspace file and masters""" + from argparse import ArgumentParser + from fontTools import configLogger + + parser = ArgumentParser(prog="varLib", description=main.__doc__) + parser.add_argument("designspace") + output_group = parser.add_mutually_exclusive_group() + output_group.add_argument( + "-o", metavar="OUTPUTFILE", dest="outfile", default=None, help="output file" + ) + output_group.add_argument( + "-d", + "--output-dir", + metavar="OUTPUTDIR", + default=None, + help="output dir (default: same as input designspace file)", + ) + parser.add_argument( + "-x", + metavar="TAG", + dest="exclude", + action="append", + default=[], + help="exclude table", + ) + parser.add_argument( + "--disable-iup", + dest="optimize", + action="store_false", + help="do not perform IUP optimization", + ) + parser.add_argument( + "--no-colr-layer-reuse", + dest="colr_layer_reuse", + action="store_false", + help="do not rebuild variable COLR table to optimize COLR layer reuse", + ) + parser.add_argument( + "--drop-implied-oncurves", + action="store_true", + help=( + "drop on-curve points that can be implied when exactly in the middle of " + "two off-curve points (only applies to TrueType fonts)" + ), + ) + parser.add_argument( + "--master-finder", + default="master_ttf_interpolatable/{stem}.ttf", + help=( + "templated string used for finding binary font " + "files given the source file names defined in the " + "designspace document. The following special strings " + "are defined: {fullname} is the absolute source file " + "name; {basename} is the file name without its " + "directory; {stem} is the basename without the file " + "extension; {ext} is the source file extension; " + "{dirname} is the directory of the absolute file " + 'name. The default value is "%(default)s".' + ), + ) + parser.add_argument( + "--variable-fonts", + default=".*", + metavar="VF_NAME", + help=( + "Filter the list of variable fonts produced from the input " + "Designspace v5 file. By default all listed variable fonts are " + "generated. To generate a specific variable font (or variable fonts) " + 'that match a given "name" attribute, you can pass as argument ' + "the full name or a regular expression. E.g.: --variable-fonts " + '"MyFontVF_WeightOnly"; or --variable-fonts "MyFontVFItalic_.*".' + ), + ) + logging_group = parser.add_mutually_exclusive_group(required=False) + logging_group.add_argument( + "-v", "--verbose", action="store_true", help="Run more verbosely." + ) + logging_group.add_argument( + "-q", "--quiet", action="store_true", help="Turn verbosity off." + ) + options = parser.parse_args(args) + + configLogger( + level=("DEBUG" if options.verbose else "ERROR" if options.quiet else "INFO") + ) + + designspace_filename = options.designspace + designspace = DesignSpaceDocument.fromfile(designspace_filename) + + vf_descriptors = designspace.getVariableFonts() + if not vf_descriptors: + parser.error(f"No variable fonts in given designspace {designspace.path!r}") + + vfs_to_build = [] + for vf in vf_descriptors: + # Skip variable fonts that do not match the user's inclusion regex if given. + if not fullmatch(options.variable_fonts, vf.name): + continue + vfs_to_build.append(vf) + + if not vfs_to_build: + parser.error(f"No variable fonts matching {options.variable_fonts!r}") + + if options.outfile is not None and len(vfs_to_build) > 1: + parser.error( + "can't specify -o because there are multiple VFs to build; " + "use --output-dir, or select a single VF with --variable-fonts" + ) + + output_dir = options.output_dir + if output_dir is None: + output_dir = os.path.dirname(designspace_filename) + + vf_name_to_output_path = {} + if len(vfs_to_build) == 1 and options.outfile is not None: + vf_name_to_output_path[vfs_to_build[0].name] = options.outfile + else: + for vf in vfs_to_build: + filename = vf.filename if vf.filename is not None else vf.name + ".{ext}" + vf_name_to_output_path[vf.name] = os.path.join(output_dir, filename) + + finder = MasterFinder(options.master_finder) + + vfs = build_many( + designspace, + finder, + exclude=options.exclude, + optimize=options.optimize, + colr_layer_reuse=options.colr_layer_reuse, + drop_implied_oncurves=options.drop_implied_oncurves, + ) + + for vf_name, vf in vfs.items(): + ext = "otf" if vf.sfntVersion == "OTTO" else "ttf" + output_path = vf_name_to_output_path[vf_name].format(ext=ext) + output_dir = os.path.dirname(output_path) + if output_dir: + os.makedirs(output_dir, exist_ok=True) + log.info("Saving variation font %s", output_path) + vf.save(output_path) if __name__ == "__main__": - import sys - if len(sys.argv) > 1: - sys.exit(main()) - import doctest - sys.exit(doctest.testmod().failed) + import sys + + if len(sys.argv) > 1: + sys.exit(main()) + import doctest + + sys.exit(doctest.testmod().failed) |