merge.py revision f2d5982826530296fd7c8f9e2d2a4dc3e070934d
1# Copyright 2013 Google, Inc. All Rights Reserved.
2#
3# Google Author(s): Behdad Esfahbod
4
5"""Font merger.
6"""
7
8import sys
9import time
10
11import fontTools
12from fontTools import misc, ttLib, cffLib
13
14def _add_method(*clazzes):
15  """Returns a decorator function that adds a new method to one or
16  more classes."""
17  def wrapper(method):
18    for clazz in clazzes:
19      assert clazz.__name__ != 'DefaultTable', 'Oops, table class not found.'
20      assert not hasattr(clazz, method.func_name), \
21          "Oops, class '%s' has method '%s'." % (clazz.__name__,
22                                                 method.func_name)
23      setattr(clazz, method.func_name, method)
24    return None
25  return wrapper
26
27
28@_add_method(fontTools.ttLib.getTableClass('maxp'))
29def merge(self, tables, fonts):
30	# TODO When we correctly merge hinting data, update these values:
31	# maxFunctionDefs, maxInstructionDefs, maxSizeOfInstructions
32	# TODO Assumes that all tables have format 1.0; safe assumption.
33	for key in set(sum((vars(table).keys() for table in tables), [])):
34			setattr(self, key, max(getattr(table, key) for table in tables))
35	return True
36
37@_add_method(fontTools.ttLib.getTableClass('head'))
38def merge(self, tables, fonts):
39	# TODO Check that unitsPerEm are the same.
40	# TODO Use bitwise ops for flags, macStyle, fontDirectionHint
41	minMembers = ['xMin', 'yMin']
42	# Negate some members
43	for key in minMembers:
44		for table in tables:
45			setattr(table, key, -getattr(table, key))
46	# Get max over members
47	for key in set(sum((vars(table).keys() for table in tables), [])):
48		setattr(self, key, max(getattr(table, key) for table in tables))
49	# Negate them back
50	for key in minMembers:
51		for table in tables:
52			setattr(table, key, -getattr(table, key))
53		setattr(self, key, -getattr(self, key))
54	return True
55
56@_add_method(fontTools.ttLib.getTableClass('hhea'))
57def merge(self, tables, fonts):
58	# TODO Check that ascent, descent, slope, etc are the same.
59	minMembers = ['descent', 'minLeftSideBearing', 'minRightSideBearing']
60	# Negate some members
61	for key in minMembers:
62		for table in tables:
63			setattr(table, key, -getattr(table, key))
64	# Get max over members
65	for key in set(sum((vars(table).keys() for table in tables), [])):
66		setattr(self, key, max(getattr(table, key) for table in tables))
67	# Negate them back
68	for key in minMembers:
69		for table in tables:
70			setattr(table, key, -getattr(table, key))
71		setattr(self, key, -getattr(self, key))
72	return True
73
74@_add_method(fontTools.ttLib.getTableClass('post'))
75def merge(self, tables, fonts):
76	# TODO Check that italicAngle, underlinePosition, underlineThickness are the same.
77	minMembers = ['underlinePosition', 'minMemType42', 'minMemType1']
78	# Negate some members
79	for key in minMembers:
80		for table in tables:
81			setattr(table, key, -getattr(table, key))
82	# Get max over members
83	keys = set(sum((vars(table).keys() for table in tables), []))
84	if 'mapping' in keys:
85		keys.remove('mapping')
86	keys.remove('extraNames')
87	for key in keys:
88		setattr(self, key, max(getattr(table, key) for table in tables))
89	# Negate them back
90	for key in minMembers:
91		for table in tables:
92			setattr(table, key, -getattr(table, key))
93		setattr(self, key, -getattr(self, key))
94	self.mapping = {}
95	for table in tables:
96		if hasattr(table, 'mapping'):
97			self.mapping.update(table.mapping)
98	self.extraNames = []
99	return True
100
101@_add_method(fontTools.ttLib.getTableClass('vmtx'),
102             fontTools.ttLib.getTableClass('hmtx'))
103def merge(self, tables, fonts):
104	self.metrics = {}
105	for table in tables:
106		self.metrics.update(table.metrics)
107	return True
108
109@_add_method(fontTools.ttLib.getTableClass('loca'))
110def merge(self, tables, fonts):
111	return True # Will be computed automatically
112
113@_add_method(fontTools.ttLib.getTableClass('glyf'))
114def merge(self, tables, fonts):
115	self.glyphs = {}
116	for table in tables:
117		self.glyphs.update(table.glyphs)
118	# TODO Drop hints?
119	return True
120
121@_add_method(fontTools.ttLib.getTableClass('prep'),
122	     fontTools.ttLib.getTableClass('fpgm'),
123	     fontTools.ttLib.getTableClass('cvt '))
124def merge(self, tables, fonts):
125	return False # Will be computed automatically
126
127
128class Options(object):
129
130  class UnknownOptionError(Exception):
131    pass
132
133  _drop_tables_default = ['fpgm', 'prep', 'cvt ', 'gasp']
134  drop_tables = _drop_tables_default
135
136  def __init__(self, **kwargs):
137
138    self.set(**kwargs)
139
140  def set(self, **kwargs):
141    for k,v in kwargs.iteritems():
142      if not hasattr(self, k):
143        raise self.UnknownOptionError("Unknown option '%s'" % k)
144      setattr(self, k, v)
145
146  def parse_opts(self, argv, ignore_unknown=False):
147    ret = []
148    opts = {}
149    for a in argv:
150      orig_a = a
151      if not a.startswith('--'):
152        ret.append(a)
153        continue
154      a = a[2:]
155      i = a.find('=')
156      op = '='
157      if i == -1:
158        if a.startswith("no-"):
159          k = a[3:]
160          v = False
161        else:
162          k = a
163          v = True
164      else:
165        k = a[:i]
166        if k[-1] in "-+":
167          op = k[-1]+'='  # Ops is '-=' or '+=' now.
168          k = k[:-1]
169        v = a[i+1:]
170      k = k.replace('-', '_')
171      if not hasattr(self, k):
172        if ignore_unknown == True or k in ignore_unknown:
173          ret.append(orig_a)
174          continue
175        else:
176          raise self.UnknownOptionError("Unknown option '%s'" % a)
177
178      ov = getattr(self, k)
179      if isinstance(ov, bool):
180        v = bool(v)
181      elif isinstance(ov, int):
182        v = int(v)
183      elif isinstance(ov, list):
184        vv = v.split(',')
185        if vv == ['']:
186          vv = []
187        vv = [int(x, 0) if len(x) and x[0] in "0123456789" else x for x in vv]
188        if op == '=':
189          v = vv
190        elif op == '+=':
191          v = ov
192          v.extend(vv)
193        elif op == '-=':
194          v = ov
195          for x in vv:
196            if x in v:
197              v.remove(x)
198        else:
199          assert 0
200
201      opts[k] = v
202    self.set(**opts)
203
204    return ret
205
206
207class Merger:
208
209	def __init__(self, options=None, log=None):
210
211		if not log:
212			log = Logger()
213		if not options:
214			options = Options()
215
216		self.options = options
217		self.log = log
218
219	def merge(self, fontfiles):
220
221		mega = ttLib.TTFont()
222
223		#
224		# Settle on a mega glyph order.
225		#
226		fonts = [ttLib.TTFont(fontfile) for fontfile in fontfiles]
227		glyphOrders = [font.getGlyphOrder() for font in fonts]
228		megaGlyphOrder = self._mergeGlyphOrders(glyphOrders)
229		# Reload fonts and set new glyph names on them.
230		# TODO Is it necessary to reload font?  I think it is.  At least
231		# it's safer, in case tables were loaded to provide glyph names.
232		fonts = [ttLib.TTFont(fontfile) for fontfile in fontfiles]
233		map(ttLib.TTFont.setGlyphOrder, fonts, glyphOrders)
234		mega.setGlyphOrder(megaGlyphOrder)
235
236		cmaps = [self._get_cmap(font) for font in fonts]
237
238		allTags = set(sum([font.keys() for font in fonts], []))
239		allTags.remove('GlyphOrder')
240		for tag in allTags:
241
242			if tag in self.options.drop_tables:
243				print "Dropping '%s'." % tag
244				continue
245
246			clazz = ttLib.getTableClass(tag)
247
248			if not hasattr(clazz, 'merge'):
249				print "Don't know how to merge '%s', dropped." % tag
250				continue
251
252			# TODO For now assume all fonts have the same tables.
253			tables = [font[tag] for font in fonts]
254			table = clazz(tag)
255			if table.merge (tables, fonts):
256				mega[tag] = table
257				print "Merged '%s'." % tag
258			else:
259				print "Dropped '%s'.  No need to merge explicitly." % tag
260
261		return mega
262
263	def _get_cmap(self, font):
264		cmap = font['cmap']
265		tables = [t for t in cmap.tables
266			    if t.platformID == 3 and t.platEncID in [1, 10]]
267		# XXX Handle format=14
268		assert len(tables)
269		# Pick table that has largest coverage
270		table = max(tables, key=lambda t: len(t.cmap))
271		return table
272
273	def _mergeGlyphOrders(self, glyphOrders):
274		"""Modifies passed-in glyphOrders to reflect new glyph names."""
275		# Simply append font index to the glyph name for now.
276		mega = []
277		for n,glyphOrder in enumerate(glyphOrders):
278			for i,glyphName in enumerate(glyphOrder):
279				glyphName += "#" + `n`
280				glyphOrder[i] = glyphName
281				mega.append(glyphName)
282		return mega
283
284
285class Logger(object):
286
287  def __init__(self, verbose=False, xml=False, timing=False):
288    self.verbose = verbose
289    self.xml = xml
290    self.timing = timing
291    self.last_time = self.start_time = time.time()
292
293  def parse_opts(self, argv):
294    argv = argv[:]
295    for v in ['verbose', 'xml', 'timing']:
296      if "--"+v in argv:
297        setattr(self, v, True)
298        argv.remove("--"+v)
299    return argv
300
301  def __call__(self, *things):
302    if not self.verbose:
303      return
304    print ' '.join(str(x) for x in things)
305
306  def lapse(self, *things):
307    if not self.timing:
308      return
309    new_time = time.time()
310    print "Took %0.3fs to %s" %(new_time - self.last_time,
311                                 ' '.join(str(x) for x in things))
312    self.last_time = new_time
313
314  def font(self, font, file=sys.stdout):
315    if not self.xml:
316      return
317    from fontTools.misc import xmlWriter
318    writer = xmlWriter.XMLWriter(file)
319    font.disassembleInstructions = False  # Work around ttLib bug
320    for tag in font.keys():
321      writer.begintag(tag)
322      writer.newline()
323      font[tag].toXML(writer, font)
324      writer.endtag(tag)
325      writer.newline()
326
327
328__all__ = [
329  'Options',
330  'Merger',
331  'Logger',
332  'main'
333]
334
335def main(args):
336
337	log = Logger()
338	args = log.parse_opts(args)
339
340	options = Options()
341	args = options.parse_opts(args)
342
343	if len(args) < 1:
344		print >>sys.stderr, "usage: pyftmerge font..."
345		sys.exit(1)
346
347	merger = Merger(options=options, log=log)
348	font = merger.merge(args)
349	outfile = 'merged.ttf'
350	font.save(outfile)
351
352if __name__ == "__main__":
353	main(sys.argv[1:])
354