diff options
Diffstat (limited to 'Lib/fontTools/varLib/__init__.py')
-rw-r--r-- | Lib/fontTools/varLib/__init__.py | 245 |
1 files changed, 156 insertions, 89 deletions
diff --git a/Lib/fontTools/varLib/__init__.py b/Lib/fontTools/varLib/__init__.py index 5a881495..36ff0d97 100644 --- a/Lib/fontTools/varLib/__init__.py +++ b/Lib/fontTools/varLib/__init__.py @@ -18,11 +18,9 @@ Then you can make a variable-font this way: API *will* change in near future. """ -from __future__ import print_function, division, absolute_import -from __future__ import unicode_literals -from fontTools.misc.py23 import * -from fontTools.misc.fixedTools import otRound -from fontTools.misc.arrayTools import Vector +from fontTools.misc.py23 import Tag, tostr +from fontTools.misc.roundTools import noRound, otRound +from fontTools.misc.vector import Vector 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 @@ -36,17 +34,20 @@ from fontTools.varLib.mvar import MVAR_ENTRIES from fontTools.varLib.iup import iup_delta_optimize from fontTools.varLib.featureVars import addFeatureVariations from fontTools.designspaceLib import DesignSpaceDocument +from functools import partial from collections import OrderedDict, namedtuple import os.path import logging from copy import deepcopy from pprint import pformat +from .errors import VarLibError, VarLibValidationError log = logging.getLogger("fontTools.varLib") - -class VarLibError(Exception): - pass +# This is a lib key for the designspace document. The value should be +# an OpenType feature tag, to be used as the FeatureVariations feature. +# If present, the DesignSpace <rules processing="..."> flag is ignored. +FEAVAR_FEATURETAG_LIB_KEY = "com.github.fonttools.varLib.featureVarsFeatureTag" # # Creation routines @@ -75,7 +76,7 @@ def _add_fvar(font, axes, instances): 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) + axis.axisNameID = nameTable.addMultilingualName(a.labelNames, font, minNameID=256) axis.flags = int(a.hidden) fvar.axes.append(axis) @@ -83,9 +84,14 @@ def _add_fvar(font, axes, instances): coordinates = instance.location if "en" not in instance.localisedStyleName: - assert instance.styleName + 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"] = tounicode(instance.styleName) + localisedStyleName["en"] = tostr(instance.styleName) else: localisedStyleName = instance.localisedStyleName @@ -94,7 +100,7 @@ def _add_fvar(font, axes, instances): inst = NamedInstance() inst.subfamilyNameID = nameTable.addMultilingualName(localisedStyleName) if psname is not None: - psname = tounicode(psname) + 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()} @@ -139,14 +145,32 @@ def _add_avar(font, axes): # Current avar requirements. We don't have to enforce # these on the designer and can deduce some ourselves, # but for now just enforce them. - assert axis.minimum == min(keys) - assert axis.maximum == max(keys) - assert axis.default in keys - # No duplicates - assert len(set(keys)) == len(keys) - assert len(set(vals)) == len(vals) + 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 - assert sorted(vals) == vals + 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) @@ -183,44 +207,21 @@ def _add_stat(font, axes): if "STAT" in font: return + from ..otlLib.builder import buildStatTable fvarTable = font['fvar'] - - STAT = font["STAT"] = newTable('STAT') - stat = STAT.table = ot.STAT() - stat.Version = 0x00010001 - - axisRecords = [] - for i, a in enumerate(fvarTable.axes): - axis = ot.AxisRecord() - axis.AxisTag = Tag(a.axisTag) - axis.AxisNameID = a.axisNameID - axis.AxisOrdering = i - axisRecords.append(axis) - - axisRecordArray = ot.AxisRecordArray() - axisRecordArray.Axis = axisRecords - # XXX these should not be hard-coded but computed automatically - stat.DesignAxisRecordSize = 8 - stat.DesignAxisCount = len(axisRecords) - stat.DesignAxisRecord = axisRecordArray - - # for the elided fallback name, we default to the base style name. - # TODO make this user-configurable via designspace document - stat.ElidedFallbackNameID = 2 + axes = [dict(tag=a.axisTag, name=a.axisNameID) for a in fvarTable.axes] + buildStatTable(font, axes) def _add_gvar(font, masterModel, master_ttfs, tolerance=0.5, optimize=True): - - assert tolerance >= 0 + 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') - gvar.version = 1 - gvar.reserved = 0 - gvar.variations = {} - glyf = font['glyf'] + defaultMasterIndex = masterModel.reverseMapping[0] # use hhea.ascent of base master as default vertical origin when vmtx is missing baseAscent = font['hhea'].ascent @@ -232,6 +233,15 @@ def _add_gvar(font, masterModel, master_ttfs, tolerance=0.5, optimize=True): m["glyf"].getCoordinatesAndControls(glyph, m, defaultVerticalOrigin=baseAscent) for m in master_ttfs ] + + 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] @@ -244,7 +254,7 @@ def _add_gvar(font, masterModel, master_ttfs, tolerance=0.5, optimize=True): # Update gvar gvar.variations[glyph] = [] - deltas = model.getDeltas(allCoords) + deltas = model.getDeltas(allCoords, round=partial(GlyphCoordinates.__round__, round=round)) supports = model.supports assert len(deltas) == len(supports) @@ -253,7 +263,7 @@ def _add_gvar(font, masterModel, master_ttfs, tolerance=0.5, optimize=True): endPts = control.endPts for i,(delta,support) in enumerate(zip(deltas[1:], supports[1:])): - if all(abs(v) <= tolerance for v in delta.array) and not isComposite: + if all(v == 0 for v in delta.array) and not isComposite: continue var = TupleVariation(support, delta) if optimize: @@ -284,6 +294,7 @@ def _add_gvar(font, masterModel, master_ttfs, tolerance=0.5, optimize=True): gvar.variations[glyph].append(var) + def _remove_TTHinting(font): for tag in ("cvar", "cvt ", "fpgm", "prep"): if tag in font: @@ -294,7 +305,7 @@ def _remove_TTHinting(font): font["glyf"].removeHinting() # TODO: Modify gasp table to deactivate gridfitting for all ranges? -def _merge_TTHinting(font, masterModel, master_ttfs, tolerance=0.5): +def _merge_TTHinting(font, masterModel, master_ttfs): log.info("Merging TT hinting") assert "cvar" not in font @@ -352,19 +363,20 @@ def _merge_TTHinting(font, masterModel, master_ttfs, tolerance=0.5): _remove_TTHinting(font) return - # We can build the cvar table now. - - cvar = font["cvar"] = newTable('cvar') - cvar.version = 1 - cvar.variations = [] - - deltas, supports = masterModel.getDeltasAndSupports(all_cvs) + 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:])): - delta = [otRound(d) for d in delta] - if all(abs(v) <= tolerance for v in delta): + if all(v == 0 for v in delta): continue var = TupleVariation(support, delta) - cvar.variations.append(var) + 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']) @@ -429,7 +441,7 @@ def _get_advance_metrics(font, masterModel, master_ttfs, vOrigDeltasAndSupports = {} for glyph in glyphOrder: vhAdvances = [metrics[glyph][0] if glyph in metrics else None for metrics in advMetricses] - vhAdvanceDeltasAndSupports[glyph] = masterModel.getDeltasAndSupports(vhAdvances) + vhAdvanceDeltasAndSupports[glyph] = masterModel.getDeltasAndSupports(vhAdvances, round=round) singleModel = models.allEqual(id(v[1]) for v in vhAdvanceDeltasAndSupports.values()) @@ -441,7 +453,7 @@ def _get_advance_metrics(font, masterModel, master_ttfs, # 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) + vOrigDeltasAndSupports[glyph] = masterModel.getDeltasAndSupports(vOrigs, round=round) directStore = None if singleModel: @@ -451,7 +463,7 @@ def _get_advance_metrics(font, masterModel, master_ttfs, varTupleIndexes = list(range(len(supports))) varData = builder.buildVarData(varTupleIndexes, [], optimize=False) for glyphName in glyphOrder: - varData.addItem(vhAdvanceDeltasAndSupports[glyphName][0]) + varData.addItem(vhAdvanceDeltasAndSupports[glyphName][0], round=noRound) varData.optimize() directStore = builder.buildVarStore(varTupleList, [varData]) @@ -461,14 +473,14 @@ def _get_advance_metrics(font, masterModel, master_ttfs, for glyphName in glyphOrder: deltas, supports = vhAdvanceDeltasAndSupports[glyphName] storeBuilder.setSupports(supports) - advMapping[glyphName] = storeBuilder.storeDeltas(deltas) + 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) + vOrigMap[glyphName] = storeBuilder.storeDeltas(deltas, round=noRound) indirectStore = storeBuilder.finish() mapping2 = indirectStore.optimize() @@ -583,6 +595,22 @@ def _add_MVAR(font, masterModel, master_ttfs, axisTags): mvar.ValueRecord = sorted(records, key=lambda r: r.ValueTag) +def _add_BASE(font, masterModel, master_ttfs, axisTags): + + log.info("Generating BASE") + + merger = VariationMerger(masterModel, axisTags, font) + merger.mergeTables(font, master_ttfs, ['BASE']) + store = merger.store_builder.finish() + + if not store.VarData: + 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") @@ -615,7 +643,7 @@ def _merge_OTL(font, model, master_fonts, axisTags): font['GPOS'].table.remap_device_varidxes(varidx_map) -def _add_GSUB_feature_variations(font, axes, internal_axis_supports, rules): +def _add_GSUB_feature_variations(font, axes, internal_axis_supports, rules, featureTag): def normalize(name, value): return models.normalizeLocation( @@ -650,7 +678,7 @@ def _add_GSUB_feature_variations(font, axes, internal_axis_supports, rules): conditional_subs.append((region, subs)) - addFeatureVariations(font, conditional_subs) + addFeatureVariations(font, conditional_subs, featureTag) _DesignSpaceData = namedtuple( @@ -663,14 +691,18 @@ _DesignSpaceData = namedtuple( "masters", "instances", "rules", + "rulesProcessingLast", + "lib", ], ) def _add_CFF2(varFont, model, master_fonts): - from .cff import (convertCFFtoCFF2, merge_region_fonts) + from .cff import merge_region_fonts glyphOrder = varFont.getGlyphOrder() - convertCFFtoCFF2(varFont) + 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) @@ -686,9 +718,10 @@ def load_designspace(designspace): masters = ds.sources if not masters: - raise VarLibError("no sources found in .designspace") + 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'})), @@ -698,11 +731,15 @@ def load_designspace(designspace): ]) # Setup axes + if not ds.axes: + raise VarLibValidationError(f"Designspace must have at least one axis.") + axes = OrderedDict() - for axis in ds.axes: + for axis_index, axis in enumerate(ds.axes): axis_name = axis.name if not axis_name: - assert axis.tag is not None + 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: @@ -711,9 +748,10 @@ def load_designspace(designspace): if not axis.labelNames: axis.labelNames.update(standard_axis_map[axis_name][1]) else: - assert axis.tag is not None + if not axis.tag: + raise VarLibValidationError(f"Axis at index {axis_index} needs a tag.") if not axis.labelNames: - axis.labelNames["en"] = tounicode(axis_name) + axis.labelNames["en"] = tostr(axis_name) axes[axis_name] = axis log.info("Axes:\n%s", pformat([axis.asdict() for axis in axes.values()])) @@ -722,14 +760,28 @@ def load_designspace(designspace): for obj in masters+instances: obj_name = obj.name or obj.styleName or '' loc = obj.location + if loc is None: + raise VarLibValidationError( + f"Source or instance '{obj_name}' has no location." + ) for axis_name in loc.keys(): - assert axis_name in axes, "Location axis '%s' unknown for '%s'." % (axis_name, obj_name) + if axis_name not in axes: + raise VarLibValidationError( + f"Location axis '{axis_name}' unknown for '{obj_name}'." + ) for axis_name,axis in axes.items(): if axis_name not in loc: - loc[axis_name] = axis.default + # NOTE: `axis.default` is always user-space, but `obj.location` always design-space. + loc[axis_name] = axis.map_forward(axis.default) else: v = axis.map_backward(loc[axis_name]) - assert axis.minimum <= v <= axis.maximum, "Location for axis '%s' (mapped to %s) out of range for '%s' [%s..%s]" % (axis_name, v, obj_name, axis.minimum, axis.maximum) + 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 @@ -750,9 +802,15 @@ def load_designspace(designspace): base_idx = None for i,m in enumerate(normalized_master_locs): if all(v == 0 for v in m.values()): - assert base_idx is None + if base_idx is not None: + raise VarLibValidationError( + "More than one base master found in Designspace." + ) base_idx = i - assert base_idx is not None, "Base master not found; no master at default location?" + 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( @@ -763,6 +821,8 @@ def load_designspace(designspace): masters, instances, ds.rules, + ds.rulesProcessingLast, + ds.lib, ) @@ -785,7 +845,7 @@ def set_default_weight_width_slant(font, location): 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.usWidthClass = %s", weight_class) + log.info("Setting OS/2.usWeightClass = %s", weight_class) font["OS/2"].usWeightClass = weight_class if "wdth" in location: @@ -854,6 +914,8 @@ def build(designspace, master_finder=lambda s:s, exclude=[], optimize=True): 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: @@ -867,8 +929,12 @@ def build(designspace, master_finder=lambda s:s, exclude=[], optimize=True): if 'cvar' not in exclude and 'glyf' in vf: _merge_TTHinting(vf, model, master_fonts) if 'GSUB' not in exclude and ds.rules: - _add_GSUB_feature_variations(vf, ds.axes, ds.internal_axis_supports, ds.rules) - if 'CFF2' not in exclude and 'CFF ' in vf: + 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 @@ -908,7 +974,7 @@ def _open_font(path, master_finder=lambda s: s): elif tp in ("TTF", "OTF", "WOFF", "WOFF2"): font = TTFont(master_path) else: - raise VarLibError("Invalid master path: %r" % master_path) + raise VarLibValidationError("Invalid master path: %r" % master_path) return font @@ -928,10 +994,10 @@ def load_masters(designspace, master_finder=lambda s: s): # 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 AttributeError( - "Designspace source '%s' specified a layer name but lacks the " - "required TTFont object in the 'font' attribute." - % (master.name or "<Unknown>") + 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) @@ -957,10 +1023,11 @@ class MasterFinder(object): 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') + parser = ArgumentParser(prog='varLib', description = main.__doc__) parser.add_argument('designspace') parser.add_argument( '-o', |