2016-04-18 16:48:13 -07:00

493 lines
14 KiB
Python

"""
Module for dealing with 'gvar'-style font variations, also known as run-time
interpolation.
The ideas here are very similar to MutatorMath. There is even code to read
MutatorMath .designspace files.
For now, if you run this file on a designspace file, it tries to find
ttf-interpolatable files for the masters and build a GX variation font from
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 GX font this way:
$ 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 fontTools.misc.py23 import *
from fontTools.ttLib import TTFont
from fontTools.ttLib.tables._n_a_m_e import NameRecord
from fontTools.ttLib.tables._f_v_a_r import table__f_v_a_r, Axis, NamedInstance
from fontTools.ttLib.tables._g_l_y_f import table__g_l_y_f, GlyphCoordinates
from fontTools.ttLib.tables._g_v_a_r import table__g_v_a_r, GlyphVariation
import operator
import warnings
import xml.etree.ElementTree as ET
import os.path
#
# Variation space, aka design space, model
#
class VariationModel(object):
"""
Locations must be in normalized space. Ie. base master
is at origin (0).
>>> from pprint import pprint
>>> locations = [ \
{'wght':100}, \
{'wght':-100}, \
{'wght':-180}, \
{'wdth':+.3}, \
{'wght':+120,'wdth':.3}, \
{'wght':+120,'wdth':.2}, \
{}, \
{'wght':+180,'wdth':.3}, \
{'wght':+180}, \
]
>>> model = VariationModel(locations, axisOrder=['wght'])
>>> pprint(model.locations)
[{},
{'wght': -100},
{'wght': -180},
{'wght': 100},
{'wght': 180},
{'wdth': 0.3},
{'wdth': 0.3, 'wght': 180},
{'wdth': 0.3, 'wght': 120},
{'wdth': 0.2, 'wght': 120}]
>>> pprint(model.deltaWeights)
[{},
{0: 1.0},
{0: 1.0},
{0: 1.0},
{0: 1.0},
{0: 1.0},
{0: 1.0, 4: 1.0, 5: 1.0},
{0: 1.0, 3: 0.25, 4: 0.75, 5: 1.0, 6: 0.75},
{0: 1.0,
3: 0.25,
4: 0.75,
5: 0.33333333333333326,
6: 0.24999999999999994,
7: 0.33333333333333326}]
"""
def __init__(self, locations, axisOrder=[]):
locations = [{k:v for k,v in loc.items() if v != 0.} for loc in locations]
keyFunc = self.getMasterLocationsSortKeyFunc(locations, axisOrder=axisOrder)
axisPoints = keyFunc.axisPoints
self.locations = sorted(locations, key=keyFunc)
# TODO Assert that locations are unique.
self.mapping = [self.locations.index(l) for l in locations] # Mapping from user's master order to our master order
self.reverseMapping = [locations.index(l) for l in self.locations] # Reverse of above
self._computeMasterSupports(axisPoints)
@staticmethod
def getMasterLocationsSortKeyFunc(locations, axisOrder=[]):
assert {} in locations, "Base master not found."
axisPoints = {}
for loc in locations:
if len(loc) != 1:
continue
axis = next(iter(loc))
value = loc[axis]
if axis not in axisPoints:
axisPoints[axis] = {0}
assert value not in axisPoints[axis]
axisPoints[axis].add(value)
def getKey(axisPoints, axisOrder):
def sign(v):
return -1 if v < 0 else +1 if v > 0 else 0
def key(loc):
rank = len(loc)
onPointAxes = [axis for axis,value in loc.items() if value in axisPoints[axis]]
orderedAxes = [axis for axis in axisOrder if axis in loc]
orderedAxes.extend([axis for axis in sorted(loc.keys()) if axis not in axisOrder])
return (
rank, # First, order by increasing rank
-len(onPointAxes), # Next, by decreasing number of onPoint axes
tuple(axisOrder.index(axis) if axis in axisOrder else 0x10000 for axis in orderedAxes), # Next, by known axes
tuple(orderedAxes), # Next, by all axes
tuple(sign(loc[axis]) for axis in orderedAxes), # Next, by signs of axis values
tuple(abs(loc[axis]) for axis in orderedAxes), # Next, by absolute value of axis values
)
return key
ret = getKey(axisPoints, axisOrder)
ret.axisPoints = axisPoints
return ret
@staticmethod
def lowerBound(value, lst):
if any(v < value for v in lst):
return max(v for v in lst if v < value)
else:
return value
@staticmethod
def upperBound(value, lst):
if any(v > value for v in lst):
return min(v for v in lst if v > value)
else:
return value
def _computeMasterSupports(self, axisPoints):
supports = []
deltaWeights = []
locations = self.locations
for i,loc in enumerate(locations):
box = {}
# Account for axisPoints first
for axis,values in axisPoints.items():
if not axis in loc:
continue
locV = loc[axis]
box[axis] = (self.lowerBound(locV, values), locV, self.upperBound(locV, values))
locAxes = set(loc.keys())
# Walk over previous masters now
for j,m in enumerate(locations[:i]):
# Master with extra axes do not participte
if not set(m.keys()).issubset(locAxes):
continue
# If it's NOT in the current box, it does not participate
relevant = True
for axis, (lower,_,upper) in box.items():
if axis in m and not (lower < m[axis] < upper):
relevant = False
break
if not relevant:
continue
# Split the box for new master
for axis,val in m.items():
assert axis in box
lower,locV,upper = box[axis]
if val < locV:
lower = val
elif locV < val:
upper = val
box[axis] = (lower,locV,upper)
supports.append(box)
deltaWeight = {}
# Walk over previous masters now, populate deltaWeight
for j,m in enumerate(locations[:i]):
scalar = 1.
support = supports[j]
for axis,v in m.items():
lower, peak, upper = support[axis]
if axis not in loc:
scalar = 0.
break
v = loc[axis]
if v == peak:
continue
if v <= lower or upper <= v:
scalar = 0.
break;
if v < peak:
scalar *= (v - peak) / (lower - peak)
else: # v > peak
scalar *= (v - peak) / (upper - peak)
if scalar:
deltaWeight[j] = scalar
deltaWeights.append(deltaWeight)
self.supports = supports
self.deltaWeights = deltaWeights
def getDeltas(self, masterValues):
count = len(self.locations)
assert len(masterValues) == len(self.deltaWeights)
mapping = self.reverseMapping
out = []
for i,weights in enumerate(self.deltaWeights):
value = masterValues[mapping[i]]
items = [out[j] * weight for j,weight in weights.items()]
delta = reduce(operator.isub, items, value)
out.append(delta)
return out
#
# .designspace routines
#
def _xmlParseLocation(et):
loc = {}
for dim in et.find('location'):
assert dim.tag == 'dimension'
name = dim.attrib['name']
value = float(dim.attrib['xvalue'])
assert name not in loc
loc[name] = value
return loc
def _designspace_load(et):
base_idx = None
masters = []
ds = et.getroot()
for master in ds.find('sources'):
name = master.attrib['name']
filename = master.attrib['filename']
isBase = master.find('info')
if isBase is not None:
assert base_idx is None
base_idx = len(masters)
loc = _xmlParseLocation(master)
masters.append((filename, loc, name))
instances = []
for instance in ds.find('instances'):
name = master.attrib['name']
family = instance.attrib['familyname']
style = instance.attrib['stylename']
filename = instance.attrib['filename']
loc = _xmlParseLocation(instance)
instances.append((filename, loc, name, family, style))
return masters, instances, base_idx
def designspace_load(filename):
return _designspace_load(ET.parse(filename))
def designspace_loads(string):
return _designspace_load(ET.fromstring(string))
#
# Creation routines
#
# TODO: Move to name table proper; also, is mac_roman ok for ASCII names?
def _AddName(font, name):
"""(font, "Bold") --> NameRecord"""
nameTable = font.get("name")
namerec = NameRecord()
namerec.nameID = 1 + max([n.nameID for n in nameTable.names] + [256])
namerec.string = name.encode("mac_roman")
namerec.platformID, namerec.platEncID, namerec.langID = (1, 0, 0)
nameTable.names.append(namerec)
return namerec
# Move to fvar table proper?
def _add_fvar(font, axes, instances):
assert "fvar" not in font
font['fvar'] = fvar = table__f_v_a_r()
for tag in sorted(axes.keys()):
axis = Axis()
axis.axisTag = tag
name, axis.minValue, axis.defaultValue, axis.maxValue = axes[tag]
axis.nameID = _AddName(font, name).nameID
fvar.axes.append(axis)
for name, coordinates in instances:
inst = NamedInstance()
inst.nameID = _AddName(font, name).nameID
inst.coordinates = coordinates
fvar.instances.append(inst)
# 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.
"""
glyf = font["glyf"]
if glyphName not in glyf.glyphs: return None
glyph = glyf[glyphName]
if glyph.isComposite():
coord = GlyphCoordinates([c.getComponentInfo()[1][-2:] for c in glyph.components])
control = [c.glyphName for c in glyph.components]
else:
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'):
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.extend([(leftSideX, 0),
(rightSideX, 0),
(0, topSideY),
(0, bottomSideY)])
return coord, control
def _add_gvar(font, axes, master_ttfs, master_locs, base_idx):
# Make copies for modification
master_ttfs = master_ttfs[:]
master_locs = [l.copy() for l in master_locs]
axis_tags = axes.keys()
# Normalize master locations. TODO Move to a separate function.
for tag,(name,lower,default,upper) in axes.items():
for l in master_locs:
v = l[tag]
if v == default:
v = 0
elif v < default:
v = (v - default) / (default - lower)
else:
v = (v - default) / (upper - default)
l[tag] = v
# Locations are normalized now
print("Normalized master positions:")
print(master_locs)
print("Generating gvar")
assert "gvar" not in font
gvar = font["gvar"] = table__g_v_a_r()
gvar.version = 1
gvar.reserved = 0
gvar.variations = {}
# Assume single-model for now.
model = VariationModel(master_locs)
model_base_idx = model.mapping[base_idx]
assert 0 == model_base_idx
for glyph in font.getGlyphOrder():
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
del allControls
gvar.variations[glyph] = []
deltas = model.getDeltas(allCoords)
supports = model.supports
assert len(deltas) == len(supports)
for i,(delta,support) in enumerate(zip(deltas, supports)):
if i == model_base_idx:
continue
var = GlyphVariation(support, delta)
gvar.variations[glyph].append(var)
def main(args=None):
import sys
if args is None:
args = sys.argv[1:]
(designspace_filename,) = args
finder = lambda s: s.replace('master_ufo', 'master_ttf_interpolatable').replace('.ufo', '.ttf')
axisMap = None # dict mapping axis id to (axis tag, axis name)
outfile = os.path.splitext(designspace_filename)[0] + '-GX.ttf'
masters, instances, base_idx = designspace_load(designspace_filename)
assert base_idx is not None, "Cannot find 'base' master; Add <info> element to one of the masters in the .designspace document."
from pprint import pprint
print("Masters:")
pprint(masters)
print("Instances:")
pprint(instances)
print("Index of base master:", base_idx)
print("Building GX")
print("Loading TTF masters")
basedir = os.path.dirname(designspace_filename)
master_ttfs = [finder(os.path.join(basedir, m[0])) 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'),
}
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.
# Set up master locations
master_locs = []
instance_locs = []
out = []
for loc in [m[1] for m in masters+instances]:
# Apply modifications for default axes; and apply tags
l = {}
for axis,value in loc.items():
tag,name = axis_map[axis]
l[tag] = value
out.append(l)
master_locs = out[:len(masters)]
instance_locs = out[len(masters):]
axis_tags = set(master_locs[0].keys())
assert all(axis_tags == set(m.keys()) for m in master_locs)
print("Axis tags:", axis_tags)
print("Master positions:")
pprint(master_locs)
# Set up axes
axes = {}
axis_names = {}
for tag,name in axis_map.values():
if tag not in axis_tags: continue
axis_names[tag] = name
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)
name = axis_names[tag]
axes[tag] = (name, lower, default, upper)
print("Axes:")
pprint(axes)
# Set up named instances
instance_list = []
for loc,instance in zip(instance_locs,instances):
style = instance[4]
instance_list.append((style, loc))
# TODO append masters as named-instances as well; needs .designspace change.
gx = TTFont(master_ttfs[base_idx])
_add_fvar(gx, axes, instance_list)
print("Setting up glyph variations")
_add_gvar(gx, axes, master_fonts, master_locs, base_idx)
print("Saving GX font", outfile)
gx.save(outfile)
if __name__ == "__main__":
import sys
if len(sys.argv) > 1:
main()
#sys.exit(0)
import doctest, sys
sys.exit(doctest.testmod().failed)