From 968c25dd7f2528c4760d90a6175e674e02accd4c Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Wed, 12 Apr 2017 21:52:29 -0700 Subject: [PATCH] [varLib] Generate avar table Fixes https://github.com/fonttools/fonttools/issues/916 --- Lib/fontTools/varLib/__init__.py | 121 ++++++++++++++++++++++++++----- Lib/fontTools/varLib/models.py | 46 +++++++----- Tests/varLib/models_test.py | 10 +-- 3 files changed, 136 insertions(+), 41 deletions(-) diff --git a/Lib/fontTools/varLib/__init__.py b/Lib/fontTools/varLib/__init__.py index c0da1129d..b2ef6586c 100644 --- a/Lib/fontTools/varLib/__init__.py +++ b/Lib/fontTools/varLib/__init__.py @@ -45,8 +45,7 @@ class VarLibError(Exception): # Creation routines # -# Move to fvar table proper? -def _add_fvar(font, axes, instances): +def _add_fvar_avar(font, axes, instances): """ Add 'fvar' table to font. @@ -56,8 +55,12 @@ def _add_fvar(font, axes, instances): and possibly 'postscriptfontname' entries. """ - assert "fvar" not in font - font['fvar'] = fvar = newTable('fvar') + assert axes + assert isinstance(axes, OrderedDict) + + log.info("Generating fvar / avar") + + fvar = newTable('fvar') nameTable = font['name'] for a in axes.values(): @@ -78,10 +81,51 @@ def _add_fvar(font, axes, instances): if psname is not None: psname = tounicode(psname) inst.postscriptNameID = nameTable.addName(psname) - inst.coordinates = {axes[k].tag:v for k,v in coordinates.items()} + inst.coordinates = {axes[k].tag:axes[k].map_backward(v) for k,v in coordinates.items()} fvar.instances.append(inst) - return fvar + avar = newTable('avar') + interesting = False + for axis in axes.values(): + curve = avar.segments[axis.tag] = {} + if not axis.map or all(k==v for k,v in axis.map.items()): + continue + interesting = True + + items = sorted(axis.map.items()) + 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. + 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) + # Ascending values + assert sorted(vals) == vals + + 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] + curve.update(zip(keys, vals)) + + if not interesting: + log.info("No need for avar") + avar = None + + assert "fvar" not in font + font['fvar'] = fvar + assert "avar" not in font + if avar: + font['avar'] = avar + + return fvar,avar # TODO Move to glyf or gvar table proper def _GetCoordinates(font, glyphName): @@ -369,16 +413,49 @@ def build(designspace_filename, master_finder=lambda s:s): ('optical', ('opsz', {'en':'Optical Size'})), ]) + # Setup axes class DesignspaceAxis(object): - pass + + @staticmethod + def _map(v, map): + keys = map.keys() + if not keys: + return v + if v in keys: + return map[v] + k = min(keys) + if v < k: + return v + map[k] - k + k = max(keys) + if v > k: + return v + map[k] - k + # Interpolate + a = max(k for k in keys if k < v) + b = min(k for k in keys if k > v) + va = map[a] + vb = map[b] + return va + (vb - va) * (v - a) / (b - a) + + def map_forward(self, v): + if self.map is None: return v + return self._map(v, self.map) + + def map_backward(self, v): + if self.map is None: return v + map = {v:k for k,v in self.map.items()} + return self._map(v, map) + axis_objects = OrderedDict() if axes is not None: for axis_dict in axes: axis_name = axis_dict.get('name') if not axis_name: axis_name = axis_dict['name'] = axis_dict['tag'] - + if 'map' not in axis_dict: + axis_dict['map'] = None + else: + axis_dict['map'] = {m['input']:m['output'] for m in axis_dict['map']} if axis_name in standard_axis_map: if 'tag' not in axis_dict: @@ -387,7 +464,7 @@ def build(designspace_filename, master_finder=lambda s:s): axis_dict['labelname'] = standard_axis_map[axis_name][1].copy() axis = DesignspaceAxis() - for item in ['name', 'tag', 'labelname', 'minimum', 'default', 'maximum']: + for item in ['name', 'tag', 'labelname', 'minimum', 'default', 'maximum', 'map']: assert item in axis_dict, 'Axis does not have "%s"' % item axis.__dict__ = axis_dict axis_objects[axis_name] = axis @@ -416,15 +493,13 @@ def build(designspace_filename, master_finder=lambda s:s): axis.default = base_loc[name] axis.minimum = min(m[name] for m in master_locs if name in m) axis.maximum = max(m[name] for m in master_locs if name in m) + axis.map = None + # TODO Fill in weight / width mapping from OS/2 table? Need loading fonts... axis_objects[name] = axis del base_idx, base_loc, axis_names, master_locs axes = axis_objects del axis_objects - axis_supports = {} - for axis in axes.values(): - axis_supports[axis.name] = (axis.minimum, axis.default, axis.maximum) - log.info("Axis supports:\n%s", pformat(axis_supports)) # Check all master and instance locations are valid and fill in defaults for obj in masters+instances: @@ -436,16 +511,25 @@ def build(designspace_filename, master_finder=lambda s:s): if axis_name not in loc: loc[axis_name] = axis.default else: - v = loc[axis_name] - assert axis.minimum <= v <= axis.maximum, "Location for axis '%s' (%s) out of range for '%s'" % (name, v, obj_name) + 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]" % (name, v, obj_name, axis.minimum, axis.maximum) - master_locs = [o['location'] for o in masters] - log.info("Master locations:\n%s", pformat(master_locs)) # Normalize master locations + + master_locs = [o['location'] for o in masters] + log.info("Internal master locations:\n%s", pformat(master_locs)) + + axis_supports = {} + for axis in axes.values(): + triple = (axis.minimum, axis.default, axis.maximum) + axis_supports[axis.name] = [axis.map_forward(v) for v in triple] + log.info("Internal axis supports:\n%s", pformat(axis_supports)) + master_locs = [models.normalizeLocation(m, axis_supports) for m in master_locs] log.info("Normalized master locations:\n%s", pformat(master_locs)) + # Find base master base_idx = None for i,m in enumerate(master_locs): @@ -456,7 +540,6 @@ def build(designspace_filename, master_finder=lambda s:s): log.info("Index of base master: %s", base_idx) - log.info("Building variable font") log.info("Loading master fonts") basedir = os.path.dirname(designspace_filename) @@ -466,7 +549,7 @@ def build(designspace_filename, master_finder=lambda s:s): vf = TTFont(master_ttfs[base_idx]) # TODO append masters as named-instances as well; needs .designspace change. - fvar = _add_fvar(vf, axes, instances) + fvar,avar = _add_fvar_avar(vf, axes, instances) # TODO Clean this up. del instances diff --git a/Lib/fontTools/varLib/models.py b/Lib/fontTools/varLib/models.py index 2c5941725..8a0ac9577 100644 --- a/Lib/fontTools/varLib/models.py +++ b/Lib/fontTools/varLib/models.py @@ -2,13 +2,33 @@ from __future__ import print_function, division, absolute_import from fontTools.misc.py23 import * -__all__ = ['normalizeLocation', 'supportScalar', 'VariationModel'] +__all__ = ['normalizeValue', 'normalizeLocation', 'supportScalar', 'VariationModel'] + +def normalizeValue(v, triple): + """Normalizes value based on a min/default/max triple. + >>> normalizeValue(400, (100, 400, 900)) + 0.0 + >>> normalizeValue(100, (100, 400, 900)) + -1.0 + >>> normalizeValue(650, (100, 400, 900)) + 0.5 + """ + lower, default, upper = triple + assert lower <= default <= upper, "invalid axis values" + v = max(min(v, upper), lower) + if v == default: + v = 0. + elif v < default: + v = (v - default) / (default - lower) + else: + v = (v - default) / (upper - default) + return v def normalizeLocation(location, axes): """Normalizes location based on axis min/default/max values from axes. >>> axes = {"wght": (100, 400, 900)} >>> normalizeLocation({"wght": 400}, axes) - {'wght': 0} + {'wght': 0.0} >>> normalizeLocation({"wght": 100}, axes) {'wght': -1.0} >>> normalizeLocation({"wght": 900}, axes) @@ -21,9 +41,9 @@ def normalizeLocation(location, axes): {'wght': -1.0} >>> axes = {"wght": (0, 0, 1000)} >>> normalizeLocation({"wght": 0}, axes) - {'wght': 0} + {'wght': 0.0} >>> normalizeLocation({"wght": -1}, axes) - {'wght': 0} + {'wght': 0.0} >>> normalizeLocation({"wght": 1000}, axes) {'wght': 1.0} >>> normalizeLocation({"wght": 500}, axes) @@ -38,22 +58,14 @@ def normalizeLocation(location, axes): >>> normalizeLocation({"wght": 500}, axes) {'wght': -0.5} >>> normalizeLocation({"wght": 1000}, axes) - {'wght': 0} + {'wght': 0.0} >>> normalizeLocation({"wght": 1001}, axes) - {'wght': 0} + {'wght': 0.0} """ out = {} - for tag,(lower,default,upper) in axes.items(): - assert lower <= default <= upper, "invalid axis values" - v = location.get(tag, default) - v = max(min(v, upper), lower) - if v == default: - v = 0 - elif v < default: - v = (v - default) / (default - lower) - else: - v = (v - default) / (upper - default) - out[tag] = v + for tag,triple in axes.items(): + v = location.get(tag, triple[1]) + out[tag] = normalizeValue(v, triple) return out def supportScalar(location, support): diff --git a/Tests/varLib/models_test.py b/Tests/varLib/models_test.py index c0d2000b4..25f1ef0b8 100644 --- a/Tests/varLib/models_test.py +++ b/Tests/varLib/models_test.py @@ -6,7 +6,7 @@ from fontTools.varLib.models import ( def test_normalizeLocation(): axes = {"wght": (100, 400, 900)} - assert normalizeLocation({"wght": 400}, axes) == {'wght': 0} + assert normalizeLocation({"wght": 400}, axes) == {'wght': 0.0} assert normalizeLocation({"wght": 100}, axes) == {'wght': -1.0} assert normalizeLocation({"wght": 900}, axes) == {'wght': 1.0} assert normalizeLocation({"wght": 650}, axes) == {'wght': 0.5} @@ -14,8 +14,8 @@ def test_normalizeLocation(): assert normalizeLocation({"wght": 0}, axes) == {'wght': -1.0} axes = {"wght": (0, 0, 1000)} - assert normalizeLocation({"wght": 0}, axes) == {'wght': 0} - assert normalizeLocation({"wght": -1}, axes) == {'wght': 0} + assert normalizeLocation({"wght": 0}, axes) == {'wght': 0.0} + assert normalizeLocation({"wght": -1}, axes) == {'wght': 0.0} assert normalizeLocation({"wght": 1000}, axes) == {'wght': 1.0} assert normalizeLocation({"wght": 500}, axes) == {'wght': 0.5} assert normalizeLocation({"wght": 1001}, axes) == {'wght': 1.0} @@ -24,8 +24,8 @@ def test_normalizeLocation(): assert normalizeLocation({"wght": 0}, axes) == {'wght': -1.0} assert normalizeLocation({"wght": -1}, axes) == {'wght': -1.0} assert normalizeLocation({"wght": 500}, axes) == {'wght': -0.5} - assert normalizeLocation({"wght": 1000}, axes) == {'wght': 0} - assert normalizeLocation({"wght": 1001}, axes) == {'wght': 0} + assert normalizeLocation({"wght": 1000}, axes) == {'wght': 0.0} + assert normalizeLocation({"wght": 1001}, axes) == {'wght': 0.0} def test_supportScalar():