455 lines
14 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
$ python fonttools/Lib/fontTools/varLib/__init__.py master_ufo/NotoSansArabic.designspace
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.ttLib.tables import otBase as otBase
from fontTools.varLib import designspace, models, builder
from fontTools.varLib.merger import merge_tables, Merger
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?
# TODO how to provide axis order?
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.
axisMap is dictionary mapping axis-id to (axis-tag, axis-name).
"""
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 k: axis_map[k][0]):
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 _all_equal(lst):
it = iter(lst)
v0 = next(it)
for v in it:
if v0 != v:
return False
return True
def buildVarDevTable(store_builder, master_values):
if _all_equal(master_values):
return None
varIdx = store_builder.storeMasters(master_values)
return builder.buildVarDevTable(varIdx)
class VariationMerger(Merger):
def __init__(self, model, axisTags, font):
Merger.__init__(self, font)
self.model = model
self.store_builder = builder.OnlineVarStoreBuilder(axisTags)
self.store_builder.setModel(model)
@VariationMerger.merger(ot.Anchor)
def merge(merger, self, lst):
assert self.Format == 1
XDeviceTable = buildVarDevTable(merger.store_builder, [a.XCoordinate for a in lst])
YDeviceTable = buildVarDevTable(merger.store_builder, [a.YCoordinate for a in lst])
if XDeviceTable or YDeviceTable:
self.Format = 3
self.XDeviceTable = XDeviceTable
self.YDeviceTable = YDeviceTable
@VariationMerger.merger(ot.PairPos)
def merge(merger, self, lst):
# We need to interecept the merger processing of the object tree here so
# that we can change the PairPos value formats after the ValueRecords are
# changed.
merger.mergeObjects(self, lst)
# Now examine the list of value records, and update to the union of format values.
vf1 = self.ValueFormat1
vf2 = self.ValueFormat2
if self.Format == 1:
for pairSet in self.PairSet:
for pairValueRecord in pairSet.PairValueRecord:
pv1 = pairValueRecord.Value1
if pv1 is not None:
vf1 |= pv1.getFormat()
pv2 = pairValueRecord.Value2
if pv2 is not None:
vf2 |= pv2.getFormat()
elif self.Format == 2:
for class1Record in self.Class1Record:
for class2Record in class1Record.Class2Record:
pv1 = class2Record.Value1
if pv1 is not None:
vf1 |= pv1.getFormat()
pv2 = class2Record.Value2
if pv2 is not None:
vf2 |= pv2.getFormat()
self.ValueFormat1 = vf1
self.ValueFormat2 = vf2
@VariationMerger.merger(otBase.ValueRecord)
def merge(merger, self, lst):
for name, tableName in [('XAdvance','XAdvDevice'),
('YAdvance','YAdvDevice'),
('XPlacement','XPlaDevice'),
('YPlacement','YPlaDevice')]:
if hasattr(self, name):
deviceTable = buildVarDevTable(merger.store_builder,
[getattr(a, name, 0) for a in lst])
if deviceTable:
setattr(self, tableName, deviceTable)
def _merge_OTL(font, model, master_fonts, axisTags, base_idx):
log.info("Merging OpenType Layout tables")
merger = VariationMerger(model, axisTags, font)
merge_tables(font, merger, 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).
"""
masters, instances = designspace.load(designspace_filename)
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 = {
'weight': ('wght', 'Weight'),
'width': ('wdth', 'Width'),
'slant': ('slnt', 'Slant'),
'optical': ('opsz', 'Optical Size'),
'custom': ('xxxx', 'Custom'),
}
axis_map = standard_axis_map
if axisMap:
axis_map = axis_map.copy()
axis_map.update(axisMap)
# 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_tags = set(master_locs[0].keys())
assert all(axis_tags == set(m.keys()) for m in master_locs)
# Set up axes
axes = {}
for tag in axis_tags:
default = master_locs[base_idx][tag]
lower = min(m[tag] for m in master_locs)
upper = max(m[tag] for m in master_locs)
if default == lower == upper:
continue
axes[tag] = (lower, default, upper)
log.info("Axes:\n%s", pformat(axes))
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, instances, axis_map)
2016-08-15 16:29:21 -07:00
# Normalize master locations
master_locs = [models.normalizeLocation(m, axes) 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
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)