522 lines
17 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
from fontTools.varLib.merger import VariationMerger, _all_equal
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")
class VarLibError(Exception):
pass
#
# Creation routines
#
# Move to fvar table proper?
def _add_fvar(font, axes, instances):
2016-09-02 17:29:22 -07:00
"""
Add 'fvar' table to font.
axes is an ordered dictionary of DesignspaceAxis objects.
2016-09-02 17:29:22 -07:00
instances is list of dictionary objects with 'location', 'stylename',
and possibly 'postscriptfontname' entries.
"""
assert "fvar" not in font
2016-07-01 15:31:00 -07:00
font['fvar'] = fvar = newTable('fvar')
nameTable = font['name']
for a in axes.values():
axis = Axis()
axis.axisTag = Tag(a.tag)
axis.minValue, axis.defaultValue, axis.maxValue = a.minimum, a.default, a.maximum
# TODO: Add all languages: https://github.com/fonttools/fonttools/issues/921
axis.axisNameID = nameTable.addName(tounicode(a.labelname['en']))
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)
inst.coordinates = {axes[k].tag: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 = round(rightSideX - leftSideX)
leftSideBearing = round(glyph.xMin - leftSideX)
# XXX Handle vertical
2017-02-25 10:59:31 -08:00
font["hmtx"].metrics[glyphName] = horizontalAdvanceWidth, leftSideBearing
2016-08-09 20:53:19 -07:00
def _add_gvar(font, model, master_ttfs, tolerance=.5):
2016-08-09 20:53:19 -07:00
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:])):
if not delta:
continue
if tolerance and max(abs(delta).array) <= tolerance:
continue
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
_MVAR_entries = {
'hasc': ('OS/2', 'sTypoAscender'), # horizontal ascender
'hdsc': ('OS/2', 'sTypoDescender'), # horizontal descender
'hlgp': ('OS/2', 'sTypoLineGap'), # horizontal line gap
'hcla': ('OS/2', 'usWinAscent'), # horizontal clipping ascent
'hcld': ('OS/2', 'usWinDescent'), # horizontal clipping descent
'vasc': ('vhea', 'ascent'), # vertical ascender
'vdsc': ('vhea', 'descent'), # vertical descender
'vlgp': ('vhea', 'lineGap'), # vertical line gap
'hcrs': ('hhea', 'caretSlopeRise'), # horizontal caret rise
'hcrn': ('hhea', 'caretSlopeRun'), # horizontal caret run
'hcof': ('hhea', 'caretOffset'), # horizontal caret offset
'vcrs': ('vhea', 'caretSlopeRise'), # vertical caret rise
'vcrn': ('vhea', 'caretSlopeRun'), # vertical caret run
'vcof': ('vhea', 'caretOffset'), # vertical caret offset
'xhgt': ('OS/2', 'sxHeight'), # x height
'cpht': ('OS/2', 'sCapHeight'), # cap height
'sbxs': ('OS/2', 'ySubscriptXSize'), # subscript em x size
'sbys': ('OS/2', 'ySubscriptYSize'), # subscript em y size
'sbxo': ('OS/2', 'ySubscriptXOffset'), # subscript em x offset
'sbyo': ('OS/2', 'ySubscriptYOffset'), # subscript em y offset
'spxs': ('OS/2', 'ySuperscriptXSize'), # superscript em x size
'spys': ('OS/2', 'ySuperscriptYSize'), # superscript em y size
'spxo': ('OS/2', 'ySuperscriptXOffset'), # superscript em x offset
'spyo': ('OS/2', 'ySuperscriptYOffset'), # superscript em y offset
'strs': ('OS/2', 'yStrikeoutSize'), # strikeout size
'stro': ('OS/2', 'yStrikeoutPosition'), # strikeout offset
'unds': ('post', 'underlineThickness'), # underline size
'undo': ('post', 'underlinePosition'), # underline offset
#'gsp0': ('gasp', 'gaspRange[0].rangeMaxPPEM'), # gaspRange[0]
#'gsp1': ('gasp', 'gaspRange[1].rangeMaxPPEM'), # gaspRange[1]
#'gsp2': ('gasp', 'gaspRange[2].rangeMaxPPEM'), # gaspRange[2]
#'gsp3': ('gasp', 'gaspRange[3].rangeMaxPPEM'), # gaspRange[3]
#'gsp4': ('gasp', 'gaspRange[4].rangeMaxPPEM'), # gaspRange[4]
#'gsp5': ('gasp', 'gaspRange[5].rangeMaxPPEM'), # gaspRange[5]
#'gsp6': ('gasp', 'gaspRange[6].rangeMaxPPEM'), # gaspRange[6]
#'gsp7': ('gasp', 'gaspRange[7].rangeMaxPPEM'), # gaspRange[7]
#'gsp8': ('gasp', 'gaspRange[8].rangeMaxPPEM'), # gaspRange[8]
#'gsp9': ('gasp', 'gaspRange[9].rangeMaxPPEM'), # gaspRange[9]
}
def _add_MVAR(font, model, master_ttfs, axisTags):
log.info("Generating MVAR")
store_builder = builder.OnlineVarStoreBuilder(axisTags)
store_builder.setModel(model)
records = []
lastTableTag = None
fontTable = None
tables = None
for tag, (tableTag, itemName) in sorted(_MVAR_entries.items(), key=lambda kv: kv[1]):
if tableTag != lastTableTag:
tables = fontTable = None
if tableTag in font:
# TODO Check all masters have same table set?
fontTable = font[tableTag]
tables = [master[tableTag] for master in master_ttfs]
lastTableTag = tableTag
if tables is None:
continue
# TODO support gasp entries
master_values = [getattr(table, itemName) for table in tables]
if _all_equal(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
MVAR = font["MVAR"] = newTable('MVAR')
mvar = MVAR.table = ot.MVAR()
mvar.Version = 0x00010000
mvar.Reserved = 0
mvar.VarStore = store_builder.finish()
mvar.ValueRecord = sorted(records, key=lambda r: r.ValueTag)
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):
"""
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).
"""
ds = designspace.load(designspace_filename)
2017-04-12 15:39:05 -07:00
axes = ds.get('axes')
masters = ds.get('sources')
if not masters:
raise VarLibError("no sources found in .designspace")
instances = ds.get('instances', [])
standard_axis_map = OrderedDict([
('weight', ('wght', {'en':'Weight'})),
('width', ('wdth', {'en':'Width'})),
('slant', ('slnt', {'en':'Slant'})),
('optical', ('opsz', {'en':'Optical Size'})),
])
# Setup axes
class DesignspaceAxis(object):
pass
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 axis_name in standard_axis_map:
if 'tag' not in axis_dict:
axis_dict['tag'] = standard_axis_map[axis_name][0]
if 'labelname' not in axis_dict:
axis_dict['labelname'] = standard_axis_map[axis_name][1].copy()
axis = DesignspaceAxis()
for item in ['name', 'tag', 'labelname', 'minimum', 'default', 'maximum']:
assert item in axis_dict, 'Axis does not have "%s"' % item
axis.__dict__ = axis_dict
axis_objects[axis_name] = axis
else:
# No <axes> element. Guess things...
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; Either add <axes> element to .designspace document, or add <info> element to one of the sources in the .designspace document."
master_locs = [o['location'] for o in masters]
base_loc = master_locs[base_idx]
axis_names = set(base_loc.keys())
assert all(name in standard_axis_map for name in axis_names), "Non-standard axis found and there exist no <axes> element."
for name,(tag,labelname) in standard_axis_map.items():
if name not in axis_names:
continue
axis = DesignspaceAxis()
axis.name = name
axis.tag = tag
axis.labelname = labelname.copy()
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_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:
obj_name = obj.get('name', obj.get('stylename', ''))
loc = obj['location']
for name in loc.keys():
assert name in axes, "Location axis '%s' unknown for '%s'." % (name, obj_name)
for axis_name,axis in axes.items():
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)
2016-08-17 17:08:58 -07:00
master_locs = [o['location'] for o in masters]
log.info("Master locations:\n%s", pformat(master_locs))
# Normalize master locations
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
2017-04-12 16:08:01 -07:00
base_idx = None
for i,m in enumerate(master_locs):
if all(v == 0 for v in m.values()):
2017-04-12 16:08:01 -07:00
assert base_idx is None
base_idx = i
assert base_idx is not None, "Base master not found; no master at default location?"
2017-04-12 16:08:01 -07:00
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)
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]
# Reload base font as target font
vf = TTFont(master_ttfs[base_idx])
2016-09-05 19:14:40 -07:00
# TODO append masters as named-instances as well; needs .designspace change.
fvar = _add_fvar(vf, axes, instances)
2016-08-15 16:29:21 -07:00
# TODO Clean this up.
del instances
del axis_supports
# Map from axis names to axis tags...
master_locs = [{axes[k].tag:v for k,v in loc.items()} for loc in master_locs]
#del axes
# From here on, we use fvar axes only
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")
_add_MVAR(vf, model, master_fonts, axisTags)
if 'glyf' in vf:
_add_gvar(vf, model, master_fonts)
_add_HVAR(vf, model, master_fonts, axisTags)
_merge_OTL(vf, model, master_fonts, axisTags, base_idx)
return vf, 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'
vf, model, master_ttfs = build(designspace_filename, finder)
log.info("Saving variation font %s", outfile)
vf.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)