408 lines
12 KiB
Python
Raw Normal View History

2016-04-15 13:56:37 -07:00
"""
Module for dealing with 'gvar'-style font variations, also known as run-time
interpolation.
2016-04-15 13:56:37 -07:00
The ideas here are very similar to MutatorMath. There is even code to read
MutatorMath .designspace files in the varLib.designspace module.
2016-04-15 13:56:37 -07:00
For now, if you run this file on a designspace file, it tries to find
ttf-interpolatable files for the masters and build a variable-font from
2016-04-15 13:56:37 -07:00
them. Such ttf-interpolatable and designspace files can be generated from
a Glyphs source, eg., using noto-source as an example:
$ fontmake -o ttf-interpolatable -g NotoSansArabic-MM.glyphs
Then you can make a variable-font this way:
2016-04-15 13:56:37 -07:00
2017-02-22 14:46:23 -06:00
$ fonttools varLib master_ufo/NotoSansArabic.designspace
2016-04-15 13:56:37 -07:00
API *will* change in near future.
"""
from __future__ import print_function, division, absolute_import
from __future__ import unicode_literals
from fontTools.misc.py23 import *
2016-07-01 15:31:00 -07:00
from fontTools.ttLib import TTFont, newTable
from fontTools.ttLib.tables._n_a_m_e import NameRecord
2016-07-01 15:31:00 -07:00
from fontTools.ttLib.tables._f_v_a_r import Axis, NamedInstance
2016-04-27 00:21:46 -07:00
from fontTools.ttLib.tables._g_l_y_f import GlyphCoordinates
from fontTools.ttLib.tables._g_v_a_r import TupleVariation
2016-07-01 15:31:00 -07:00
from fontTools.ttLib.tables import otTables as ot
from fontTools.varLib import builder, designspace, models
2017-01-25 20:11:35 -08:00
from fontTools.varLib.merger import VariationMerger
from collections import OrderedDict
2016-04-18 16:48:02 -07:00
import warnings
import os.path
import logging
from pprint import pformat
log = logging.getLogger("fontTools.varLib")
#
# Creation routines
#
# Move to fvar table proper?
2016-08-17 17:08:58 -07:00
def _add_fvar(font, axes, instances, axis_map):
2016-09-02 17:29:22 -07:00
"""
Add 'fvar' table to font.
axes is a dictionary mapping axis-id to axis (min,default,max)
coordinate values.
instances is list of dictionary objects with 'location', 'stylename',
and possibly 'postscriptfontname' entries.
axis_map is dictionary mapping axis-id to (axis-tag, axis-name).
2016-09-02 17:29:22 -07:00
"""
assert "fvar" not in font
2016-07-01 15:31:00 -07:00
font['fvar'] = fvar = newTable('fvar')
nameTable = font['name']
for iden in sorted(axes.keys(), key=lambda i: axis_map.keys().index(i)):
axis = Axis()
2016-08-17 17:08:58 -07:00
axis.axisTag = Tag(axis_map[iden][0])
axis.minValue, axis.defaultValue, axis.maxValue = axes[iden]
axisName = tounicode(axis_map[iden][1])
axis.axisNameID = nameTable.addName(axisName)
fvar.axes.append(axis)
2016-09-02 17:29:22 -07:00
for instance in instances:
coordinates = instance['location']
name = tounicode(instance['stylename'])
psname = instance.get('postscriptfontname')
2016-09-02 17:29:22 -07:00
inst = NamedInstance()
inst.subfamilyNameID = nameTable.addName(name)
if psname is not None:
psname = tounicode(psname)
inst.postscriptNameID = nameTable.addName(psname)
2016-08-17 17:08:58 -07:00
inst.coordinates = {axis_map[k][0]:v for k,v in coordinates.items()}
fvar.instances.append(inst)
return fvar
2016-04-14 23:55:11 -07:00
# TODO Move to glyf or gvar table proper
def _GetCoordinates(font, glyphName):
"""font, glyphName --> glyph coordinates as expected by "gvar" table
The result includes four "phantom points" for the glyph metrics,
as mandated by the "gvar" spec.
"""
2016-04-14 23:55:11 -07:00
glyf = font["glyf"]
if glyphName not in glyf.glyphs: return None
glyph = glyf[glyphName]
if glyph.isComposite():
coord = GlyphCoordinates([(getattr(c, 'x', 0),getattr(c, 'y', 0)) for c in glyph.components])
2016-04-14 23:55:11 -07:00
control = [c.glyphName for c in glyph.components]
else:
2016-04-14 23:55:11 -07:00
allData = glyph.getCoordinates(glyf)
coord = allData[0]
control = allData[1:]
# Add phantom points for (left, right, top, bottom) positions.
horizontalAdvanceWidth, leftSideBearing = font["hmtx"].metrics[glyphName]
if not hasattr(glyph, 'xMin'):
2016-04-14 23:55:11 -07:00
glyph.recalcBounds(glyf)
leftSideX = glyph.xMin - leftSideBearing
rightSideX = leftSideX + horizontalAdvanceWidth
# XXX these are incorrect. Load vmtx and fix.
topSideY = glyph.yMax
bottomSideY = -glyph.yMin
coord = coord.copy()
coord.extend([(leftSideX, 0),
(rightSideX, 0),
(0, topSideY),
(0, bottomSideY)])
2016-04-14 23:55:11 -07:00
return coord, control
2016-04-27 01:17:09 -07:00
# TODO Move to glyf or gvar table proper
def _SetCoordinates(font, glyphName, coord):
glyf = font["glyf"]
assert glyphName in glyf.glyphs
glyph = glyf[glyphName]
# Handle phantom points for (left, right, top, bottom) positions.
assert len(coord) >= 4
if not hasattr(glyph, 'xMin'):
glyph.recalcBounds(glyf)
leftSideX = coord[-4][0]
rightSideX = coord[-3][0]
topSideY = coord[-2][1]
bottomSideY = coord[-1][1]
for _ in range(4):
del coord[-1]
if glyph.isComposite():
assert len(coord) == len(glyph.components)
for p,comp in zip(coord, glyph.components):
if hasattr(comp, 'x'):
comp.x,comp.y = p
2016-04-27 01:17:09 -07:00
elif glyph.numberOfContours is 0:
assert len(coord) == 0
else:
assert len(coord) == len(glyph.coordinates)
glyph.coordinates = coord
glyph.recalcBounds(glyf)
horizontalAdvanceWidth = rightSideX - leftSideX
leftSideBearing = glyph.xMin - leftSideX
# XXX Handle vertical
# XXX Remove the round when https://github.com/behdad/fonttools/issues/593 is fixed
font["hmtx"].metrics[glyphName] = int(round(horizontalAdvanceWidth)), int(round(leftSideBearing))
2016-08-09 20:53:19 -07:00
def _add_gvar(font, model, master_ttfs):
log.info("Generating gvar")
2016-04-14 23:55:11 -07:00
assert "gvar" not in font
2016-07-01 15:31:00 -07:00
gvar = font["gvar"] = newTable('gvar')
gvar.version = 1
gvar.reserved = 0
gvar.variations = {}
2016-04-14 23:55:11 -07:00
for glyph in font.getGlyphOrder():
2016-04-14 23:55:11 -07:00
allData = [_GetCoordinates(m, glyph) for m in master_ttfs]
allCoords = [d[0] for d in allData]
allControls = [d[1] for d in allData]
control = allControls[0]
if (any(c != control for c in allControls)):
warnings.warn("glyph %s has incompatible masters; skipping" % glyph)
continue
2016-04-14 23:55:11 -07:00
del allControls
2016-07-01 15:31:00 -07:00
# Update gvar
gvar.variations[glyph] = []
deltas = model.getDeltas(allCoords)
supports = model.supports
assert len(deltas) == len(supports)
2016-07-01 15:31:00 -07:00
for i,(delta,support) in enumerate(zip(deltas[1:], supports[1:])):
var = TupleVariation(support, delta)
gvar.variations[glyph].append(var)
def _add_HVAR(font, model, master_ttfs, axisTags):
2016-07-01 15:31:00 -07:00
log.info("Generating HVAR")
2016-08-09 20:53:19 -07:00
hAdvanceDeltas = {}
metricses = [m["hmtx"].metrics for m in master_ttfs]
for glyph in font.getGlyphOrder():
hAdvances = [metrics[glyph][0] for metrics in metricses]
# TODO move round somewhere else?
hAdvanceDeltas[glyph] = tuple(round(d) for d in model.getDeltas(hAdvances)[1:])
2016-07-01 15:31:00 -07:00
# We only support the direct mapping right now.
supports = model.supports[1:]
varTupleList = builder.buildVarRegionList(supports, axisTags)
2016-07-01 15:31:00 -07:00
varTupleIndexes = list(range(len(supports)))
n = len(supports)
items = []
zeroes = [0]*n
for glyphName in font.getGlyphOrder():
items.append(hAdvanceDeltas.get(glyphName, zeroes))
while items and items[-1] is zeroes:
del items[-1]
advanceMapping = None
# Add indirect mapping to save on duplicates
uniq = set(items)
# TODO Improve heuristic
if (len(items) - len(uniq)) * len(varTupleIndexes) > len(items):
newItems = sorted(uniq)
mapper = {v:i for i,v in enumerate(newItems)}
mapping = [mapper[item] for item in items]
while len(mapping) > 1 and mapping[-1] == mapping[-2]:
del mapping[-1]
advanceMapping = builder.buildVarIdxMap(mapping)
items = newItems
del mapper, mapping, newItems
del uniq
varData = builder.buildVarData(varTupleIndexes, items)
varStore = builder.buildVarStore(varTupleList, [varData])
2016-07-01 15:31:00 -07:00
assert "HVAR" not in font
HVAR = font["HVAR"] = newTable('HVAR')
hvar = HVAR.table = ot.HVAR()
hvar.Version = 0x00010000
2016-07-01 15:31:00 -07:00
hvar.VarStore = varStore
hvar.AdvWidthMap = advanceMapping
hvar.LsbMap = hvar.RsbMap = None
2016-07-01 15:31:00 -07:00
def _merge_OTL(font, model, master_fonts, axisTags, base_idx):
log.info("Merging OpenType Layout tables")
merger = VariationMerger(model, axisTags, font)
2017-01-25 20:11:35 -08:00
merger.mergeTables(font, master_fonts, axisTags, base_idx, ['GPOS'])
store = merger.store_builder.finish()
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.Version = 0x00010003
GDEF.VarStore = store
2016-08-15 11:14:52 -07:00
def build(designspace_filename, master_finder=lambda s:s, axisMap=None):
"""
Build variation font from a designspace file.
2016-04-14 00:31:17 -07:00
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).
2016-04-14 00:31:17 -07:00
2016-09-02 17:29:22 -07:00
If axisMap is set, it should be dictionary mapping axis-id to
(axis-tag, axis-name).
"""
ds = designspace.load(designspace_filename)
axes = ds['axes']
masters = ds['masters']
instances = ds['instances']
base_idx = None
for i,m in enumerate(masters):
if 'info' in m and m['info']['copy']:
assert base_idx is None
base_idx = i
assert base_idx is not None, "Cannot find 'base' master; Add <info> element to one of the masters in the .designspace document."
2016-04-14 00:31:17 -07:00
log.info("Index of base master: %s", base_idx)
log.info("Building variable font")
log.info("Loading TTF masters")
basedir = os.path.dirname(designspace_filename)
master_ttfs = [master_finder(os.path.join(basedir, m['filename'])) for m in masters]
master_fonts = [TTFont(ttf_path) for ttf_path in master_ttfs]
standard_axis_map = OrderedDict([
('weight', ('wght', 'Weight')),
('width', ('wdth', 'Width')),
('slant', ('slnt', 'Slant')),
('optical', ('opsz', 'Optical Size')),
('custom', ('xxxx', 'Custom'))
])
if axisMap:
# a dictionary mapping axis-id to (axis-tag, axis-name) was provided
axis_map = standard_axis_map.copy()
axis_map.update(axisMap)
elif axes:
# the designspace file loaded had an <axes> element.
# honor the order of the axes
axis_map = OrderedDict()
for axis in axes:
axis_name = axis['name']
if axis_name in standard_axis_map:
axis_map[axis_name] = standard_axis_map[axis_name]
else:
tag = axis['tag']
assert axis['labelname']['en']
label = axis['labelname']['en']
axis_map[axis_name] = (tag, label)
else:
axis_map = standard_axis_map
# TODO: For weight & width, use OS/2 values and setup 'avar' mapping.
2016-08-17 17:08:58 -07:00
master_locs = [o['location'] for o in masters]
axis_names = set(master_locs[0].keys())
assert all(axis_names == set(m.keys()) for m in master_locs)
# Set up axes
axes_dict = {}
if axes:
# the designspace file loaded had an <axes> element
for axis in axes:
default = axis['default']
lower = axis['minimum']
upper = axis['maximum']
name = axis['name']
axes_dict[name] = (lower, default, upper)
else:
for name in axis_names:
default = master_locs[base_idx][name]
lower = min(m[name] for m in master_locs)
upper = max(m[name] for m in master_locs)
if default == lower == upper:
continue
axes_dict[name] = (lower, default, upper)
log.info("Axes:\n%s", pformat(axes_dict))
assert all(name in axis_map for name in axes_dict.keys())
log.info("Master locations:\n%s", pformat(master_locs))
2016-09-05 19:14:40 -07:00
# We can use the base font straight, but it's faster to load it again since
# then we won't be recompiling the existing ('glyf', 'hmtx', ...) tables.
#gx = master_fonts[base_idx]
gx = TTFont(master_ttfs[base_idx])
2016-09-02 17:29:22 -07:00
# TODO append masters as named-instances as well; needs .designspace change.
fvar = _add_fvar(gx, axes_dict, instances, axis_map)
2016-08-15 16:29:21 -07:00
# Normalize master locations
master_locs = [models.normalizeLocation(m, axes_dict) for m in master_locs]
2016-09-05 19:14:40 -07:00
log.info("Normalized master locations:\n%s", pformat(master_locs))
2016-08-15 16:29:21 -07:00
# TODO Clean this up.
del instances
del axes_dict
master_locs = [{axis_map[k][0]:v for k,v in loc.items()} for loc in master_locs]
#instance_locs = [{axis_map[k][0]:v for k,v in loc.items()} for loc in instance_locs]
axisTags = [axis.axisTag for axis in fvar.axes]
2016-08-15 16:29:21 -07:00
# Assume single-model for now.
model = models.VariationModel(master_locs)
assert 0 == model.mapping[base_idx]
2016-08-09 20:53:19 -07:00
log.info("Building variations tables")
if 'glyf' in gx:
_add_gvar(gx, model, master_fonts)
_add_HVAR(gx, model, master_fonts, axisTags)
_merge_OTL(gx, model, master_fonts, axisTags, base_idx)
return gx, model, master_ttfs
def main(args=None):
from argparse import ArgumentParser
from fontTools import configLogger
parser = ArgumentParser(prog='varLib')
parser.add_argument('designspace')
2016-11-02 20:54:50 -07:00
options = parser.parse_args(args)
# TODO: allow user to configure logging via command-line options
configLogger(level="INFO")
designspace_filename = options.designspace
finder = lambda s: s.replace('master_ufo', 'master_ttf_interpolatable').replace('.ufo', '.ttf')
outfile = os.path.splitext(designspace_filename)[0] + '-VF.ttf'
gx, model, master_ttfs = build(designspace_filename, finder)
log.info("Saving variation font %s", outfile)
gx.save(outfile)
2016-04-14 00:31:17 -07:00
2016-04-13 23:51:54 -07:00
if __name__ == "__main__":
2016-04-14 00:31:17 -07:00
import sys
if len(sys.argv) > 1:
sys.exit(main())
2016-04-13 23:51:54 -07:00
import doctest, sys
sys.exit(doctest.testmod().failed)