merge.py revision c2e27fd88f54fcc081049dabce59889cdc87450e
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
13from fontTools.ttLib.tables import otTables
14
15def _add_method(*clazzes):
16	"""Returns a decorator function that adds a new method to one or
17	more classes."""
18	def wrapper(method):
19		for clazz in clazzes:
20			assert clazz.__name__ != 'DefaultTable', 'Oops, table class not found.'
21			assert not hasattr(clazz, method.func_name), \
22				"Oops, class '%s' has method '%s'." % (clazz.__name__,
23								       method.func_name)
24		setattr(clazz, method.func_name, method)
25		return None
26	return wrapper
27
28
29@_add_method(ttLib.getTableClass('maxp'))
30def merge(self, m):
31	# TODO When we correctly merge hinting data, update these values:
32	# maxFunctionDefs, maxInstructionDefs, maxSizeOfInstructions
33	# TODO Assumes that all tables have format 1.0; safe assumption.
34	for key in set(sum((vars(table).keys() for table in m.tables), [])):
35		setattr(self, key, max(getattr(table, key) for table in m.tables))
36	return True
37
38@_add_method(ttLib.getTableClass('head'))
39def merge(self, m):
40	# TODO Check that unitsPerEm are the same.
41	# TODO Use bitwise ops for flags, macStyle, fontDirectionHint
42	minMembers = ['xMin', 'yMin']
43	# Negate some members
44	for key in minMembers:
45		for table in m.tables:
46			setattr(table, key, -getattr(table, key))
47	# Get max over members
48	for key in set(sum((vars(table).keys() for table in m.tables), [])):
49		setattr(self, key, max(getattr(table, key) for table in m.tables))
50	# Negate them back
51	for key in minMembers:
52		for table in m.tables:
53			setattr(table, key, -getattr(table, key))
54		setattr(self, key, -getattr(self, key))
55	return True
56
57@_add_method(ttLib.getTableClass('hhea'))
58def merge(self, m):
59	# TODO Check that ascent, descent, slope, etc are the same.
60	minMembers = ['descent', 'minLeftSideBearing', 'minRightSideBearing']
61	# Negate some members
62	for key in minMembers:
63		for table in m.tables:
64			setattr(table, key, -getattr(table, key))
65	# Get max over members
66	for key in set(sum((vars(table).keys() for table in m.tables), [])):
67		setattr(self, key, max(getattr(table, key) for table in m.tables))
68	# Negate them back
69	for key in minMembers:
70		for table in m.tables:
71			setattr(table, key, -getattr(table, key))
72		setattr(self, key, -getattr(self, key))
73	return True
74
75@_add_method(ttLib.getTableClass('OS/2'))
76def merge(self, m):
77	# TODO Check that weight/width/subscript/superscript/etc are the same.
78	# TODO Bitwise ops for UnicodeRange/CodePageRange.
79	# TODO Pretty much all fields generated here have bogus values.
80	# Get max over members
81	for key in set(sum((vars(table).keys() for table in m.tables), [])):
82		setattr(self, key, max(getattr(table, key) for table in m.tables))
83	return True
84
85@_add_method(ttLib.getTableClass('post'))
86def merge(self, m):
87	# TODO Check that italicAngle, underlinePosition, underlineThickness are the same.
88	minMembers = ['underlinePosition', 'minMemType42', 'minMemType1']
89	# Negate some members
90	for key in minMembers:
91		for table in m.tables:
92			setattr(table, key, -getattr(table, key))
93	# Get max over members
94	keys = set(sum((vars(table).keys() for table in m.tables), []))
95	if 'mapping' in keys:
96		keys.remove('mapping')
97	keys.remove('extraNames')
98	for key in keys:
99		setattr(self, key, max(getattr(table, key) for table in m.tables))
100	# Negate them back
101	for key in minMembers:
102		for table in m.tables:
103			setattr(table, key, -getattr(table, key))
104		setattr(self, key, -getattr(self, key))
105	self.mapping = {}
106	for table in m.tables:
107		if hasattr(table, 'mapping'):
108			self.mapping.update(table.mapping)
109	self.extraNames = []
110	return True
111
112@_add_method(ttLib.getTableClass('vmtx'),
113             ttLib.getTableClass('hmtx'))
114def merge(self, m):
115	self.metrics = {}
116	for table in m.tables:
117		self.metrics.update(table.metrics)
118	return True
119
120@_add_method(ttLib.getTableClass('loca'))
121def merge(self, m):
122	return True # Will be computed automatically
123
124@_add_method(ttLib.getTableClass('glyf'))
125def merge(self, m):
126	self.glyphs = {}
127	for table in m.tables:
128		self.glyphs.update(table.glyphs)
129	# Drop hints for now, since we don't remap functions / CVT values.
130	for g in self.glyphs.values():
131		g.removeHinting()
132	return True
133
134@_add_method(ttLib.getTableClass('prep'),
135	     ttLib.getTableClass('fpgm'),
136	     ttLib.getTableClass('cvt '))
137def merge(self, m):
138	return False # Will be computed automatically
139
140@_add_method(ttLib.getTableClass('cmap'))
141def merge(self, m):
142	# TODO Handle format=14.
143	cmapTables = [t for table in m.tables for t in table.tables
144		      if t.platformID == 3 and t.platEncID in [1, 10]]
145	# TODO Better handle format-4 and format-12 coexisting in same font.
146	# TODO Insert both a format-4 and format-12 if needed.
147	module = ttLib.getTableModule('cmap')
148	assert all(t.format in [4, 12] for t in cmapTables)
149	format = max(t.format for t in cmapTables)
150	cmapTable = module.cmap_classes[format](format)
151	cmapTable.cmap = {}
152	cmapTable.platformID = 3
153	cmapTable.platEncID = max(t.platEncID for t in cmapTables)
154	cmapTable.language = 0
155	for table in cmapTables:
156		# TODO handle duplicates.
157		cmapTable.cmap.update(table.cmap)
158	self.tableVersion = 0
159	self.tables = [cmapTable]
160	self.numSubTables = len(self.tables)
161	return True
162
163@_add_method(ttLib.getTableClass('GDEF'))
164def merge(self, m):
165	self.table = otTables.GDEF()
166	self.table.Version = 1.0
167
168	if any(t.table.LigCaretList for t in m.tables):
169		glyphs = []
170		ligGlyphs = []
171		for table in m.tables:
172			if table.table.LigCaretList:
173				glyphs.extend(table.table.LigCaretList.Coverage.glyphs)
174				ligGlyphs.extend(table.table.LigCaretList.LigGlyph)
175		coverage = otTables.Coverage()
176		coverage.glyphs = glyphs
177		ligCaretList = otTables.LigCaretList()
178		ligCaretList.Coverage = coverage
179		ligCaretList.LigGlyph = ligGlyphs
180		ligCaretList.GlyphCount = len(ligGlyphs)
181		self.table.LigCaretList = ligCaretList
182	else:
183		self.table.LigCaretList = None
184
185	if any(t.table.MarkAttachClassDef for t in m.tables):
186		classDefs = {}
187		for table in m.tables:
188			if table.table.MarkAttachClassDef:
189				classDefs.update(table.table.MarkAttachClassDef.classDefs)
190		self.table.MarkAttachClassDef = otTables.MarkAttachClassDef()
191		self.table.MarkAttachClassDef.classDefs = classDefs
192	else:
193		self.table.MarkAttachClassDef = None
194
195	if any(t.table.GlyphClassDef for t in m.tables):
196		classDefs = {}
197		for table in m.tables:
198			if table.table.GlyphClassDef:
199				classDefs.update(table.table.GlyphClassDef.classDefs)
200		self.table.GlyphClassDef = otTables.GlyphClassDef()
201		self.table.GlyphClassDef.classDefs = classDefs
202	else:
203		self.table.GlyphClassDef = None
204
205	if any(t.table.AttachList for t in m.tables):
206		glyphs = []
207		attachPoints = []
208		for table in m.tables:
209			if table.table.AttachList:
210				glyphs.extend(table.table.AttachList.Coverage.glyphs)
211				attachPoints.extend(table.table.AttachList.AttachPoint)
212		coverage = otTables.Coverage()
213		coverage.glyphs = glyphs
214		attachList = otTables.AttachList()
215		attachList.Coverage = coverage
216		attachList.AttachPoint = attachPoints
217		attachList.GlyphCount = len(attachPoints)
218		self.table.AttachList = attachList
219	else:
220		self.table.AttachList = None
221
222	return True
223
224
225class Options(object):
226
227  class UnknownOptionError(Exception):
228    pass
229
230  _drop_tables_default = ['fpgm', 'prep', 'cvt ', 'gasp']
231  drop_tables = _drop_tables_default
232
233  def __init__(self, **kwargs):
234
235    self.set(**kwargs)
236
237  def set(self, **kwargs):
238    for k,v in kwargs.iteritems():
239      if not hasattr(self, k):
240        raise self.UnknownOptionError("Unknown option '%s'" % k)
241      setattr(self, k, v)
242
243  def parse_opts(self, argv, ignore_unknown=False):
244    ret = []
245    opts = {}
246    for a in argv:
247      orig_a = a
248      if not a.startswith('--'):
249        ret.append(a)
250        continue
251      a = a[2:]
252      i = a.find('=')
253      op = '='
254      if i == -1:
255        if a.startswith("no-"):
256          k = a[3:]
257          v = False
258        else:
259          k = a
260          v = True
261      else:
262        k = a[:i]
263        if k[-1] in "-+":
264          op = k[-1]+'='  # Ops is '-=' or '+=' now.
265          k = k[:-1]
266        v = a[i+1:]
267      k = k.replace('-', '_')
268      if not hasattr(self, k):
269        if ignore_unknown == True or k in ignore_unknown:
270          ret.append(orig_a)
271          continue
272        else:
273          raise self.UnknownOptionError("Unknown option '%s'" % a)
274
275      ov = getattr(self, k)
276      if isinstance(ov, bool):
277        v = bool(v)
278      elif isinstance(ov, int):
279        v = int(v)
280      elif isinstance(ov, list):
281        vv = v.split(',')
282        if vv == ['']:
283          vv = []
284        vv = [int(x, 0) if len(x) and x[0] in "0123456789" else x for x in vv]
285        if op == '=':
286          v = vv
287        elif op == '+=':
288          v = ov
289          v.extend(vv)
290        elif op == '-=':
291          v = ov
292          for x in vv:
293            if x in v:
294              v.remove(x)
295        else:
296          assert 0
297
298      opts[k] = v
299    self.set(**opts)
300
301    return ret
302
303
304class Merger:
305
306	def __init__(self, options=None, log=None):
307
308		if not log:
309			log = Logger()
310		if not options:
311			options = Options()
312
313		self.options = options
314		self.log = log
315
316	def merge(self, fontfiles):
317
318		mega = ttLib.TTFont()
319
320		#
321		# Settle on a mega glyph order.
322		#
323		fonts = [ttLib.TTFont(fontfile) for fontfile in fontfiles]
324		glyphOrders = [font.getGlyphOrder() for font in fonts]
325		megaGlyphOrder = self._mergeGlyphOrders(glyphOrders)
326		# Reload fonts and set new glyph names on them.
327		# TODO Is it necessary to reload font?  I think it is.  At least
328		# it's safer, in case tables were loaded to provide glyph names.
329		fonts = [ttLib.TTFont(fontfile) for fontfile in fontfiles]
330		for font,glyphOrder in zip(fonts, glyphOrders):
331			font.setGlyphOrder(glyphOrder)
332		mega.setGlyphOrder(megaGlyphOrder)
333
334		allTags = reduce(set.union, (font.keys() for font in fonts), set())
335		allTags.remove('GlyphOrder')
336		for tag in allTags:
337
338			if tag in self.options.drop_tables:
339				self.log("Dropping '%s'." % tag)
340				continue
341
342			clazz = ttLib.getTableClass(tag)
343
344			if not hasattr(clazz, 'merge'):
345				self.log("Don't know how to merge '%s', dropped." % tag)
346				continue
347
348			# TODO For now assume all fonts have the same tables.
349			self.tables = [font[tag] for font in fonts]
350			table = clazz(tag)
351			if table.merge (self):
352				mega[tag] = table
353				self.log("Merged '%s'." % tag)
354			else:
355				self.log("Dropped '%s'.  No need to merge explicitly." % tag)
356			self.log.lapse("merge '%s'" % tag)
357			del self.tables
358
359		return mega
360
361	def _mergeGlyphOrders(self, glyphOrders):
362		"""Modifies passed-in glyphOrders to reflect new glyph names.
363		Returns glyphOrder for the merged font."""
364		# Simply append font index to the glyph name for now.
365		# TODO Even this simplistic numbering can result in conflicts.
366		# But then again, we have to improve this soon anyway.
367		mega = []
368		for n,glyphOrder in enumerate(glyphOrders):
369			for i,glyphName in enumerate(glyphOrder):
370				glyphName += "#" + `n`
371				glyphOrder[i] = glyphName
372				mega.append(glyphName)
373		return mega
374
375
376class Logger(object):
377
378  def __init__(self, verbose=False, xml=False, timing=False):
379    self.verbose = verbose
380    self.xml = xml
381    self.timing = timing
382    self.last_time = self.start_time = time.time()
383
384  def parse_opts(self, argv):
385    argv = argv[:]
386    for v in ['verbose', 'xml', 'timing']:
387      if "--"+v in argv:
388        setattr(self, v, True)
389        argv.remove("--"+v)
390    return argv
391
392  def __call__(self, *things):
393    if not self.verbose:
394      return
395    print ' '.join(str(x) for x in things)
396
397  def lapse(self, *things):
398    if not self.timing:
399      return
400    new_time = time.time()
401    print "Took %0.3fs to %s" %(new_time - self.last_time,
402                                 ' '.join(str(x) for x in things))
403    self.last_time = new_time
404
405  def font(self, font, file=sys.stdout):
406    if not self.xml:
407      return
408    from fontTools.misc import xmlWriter
409    writer = xmlWriter.XMLWriter(file)
410    font.disassembleInstructions = False  # Work around ttLib bug
411    for tag in font.keys():
412      writer.begintag(tag)
413      writer.newline()
414      font[tag].toXML(writer, font)
415      writer.endtag(tag)
416      writer.newline()
417
418
419__all__ = [
420  'Options',
421  'Merger',
422  'Logger',
423  'main'
424]
425
426def main(args):
427
428	log = Logger()
429	args = log.parse_opts(args)
430
431	options = Options()
432	args = options.parse_opts(args)
433
434	if len(args) < 1:
435		print >>sys.stderr, "usage: pyftmerge font..."
436		sys.exit(1)
437
438	merger = Merger(options=options, log=log)
439	font = merger.merge(args)
440	outfile = 'merged.ttf'
441	font.save(outfile)
442	log.lapse("compile and save font")
443
444	log.last_time = log.start_time
445	log.lapse("make one with everything(TOTAL TIME)")
446
447if __name__ == "__main__":
448	main(sys.argv[1:])
449