merge.py revision c14ab48ae84ce77bbcc0942baaa18f8099edb447
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	# TODO Drop hints?
130	return True
131
132@_add_method(ttLib.getTableClass('prep'),
133	     ttLib.getTableClass('fpgm'),
134	     ttLib.getTableClass('cvt '))
135def merge(self, m):
136	return False # Will be computed automatically
137
138@_add_method(ttLib.getTableClass('cmap'))
139def merge(self, m):
140	# TODO Handle format=14.
141	cmapTables = [t for table in m.tables for t in table.tables
142		      if t.platformID == 3 and t.platEncID in [1, 10]]
143	# TODO Better handle format-4 and format-12 coexisting in same font.
144	# TODO Insert both a format-4 and format-12 if needed.
145	module = ttLib.getTableModule('cmap')
146	assert all(t.format in [4, 12] for t in cmapTables)
147	format = max(t.format for t in cmapTables)
148	cmapTable = module.cmap_classes[format](format)
149	cmapTable.cmap = {}
150	cmapTable.platformID = 3
151	cmapTable.platEncID = max(t.platEncID for t in cmapTables)
152	cmapTable.language = 0
153	for table in cmapTables:
154		# TODO handle duplicates.
155		cmapTable.cmap.update(table.cmap)
156	self.tableVersion = 0
157	self.tables = [cmapTable]
158	self.numSubTables = len(self.tables)
159	return True
160
161@_add_method(ttLib.getTableClass('GDEF'))
162def merge(self, m):
163	self.table = otTables.GDEF()
164	self.table.Version = 1.0
165
166	if any(t.table.LigCaretList for t in m.tables):
167		glyphs = []
168		ligGlyphs = []
169		for table in m.tables:
170			if table.table.LigCaretList:
171				glyphs.extend(table.table.LigCaretList.Coverage.glyphs)
172				ligGlyphs.extend(table.table.LigCaretList.LigGlyph)
173		coverage = otTables.Coverage()
174		coverage.glyphs = glyphs
175		ligCaretList = otTables.LigCaretList()
176		ligCaretList.Coverage = coverage
177		ligCaretList.LigGlyph = ligGlyphs
178		ligCaretList.GlyphCount = len(ligGlyphs)
179		self.table.LigCaretList = ligCaretList
180	else:
181		self.table.LigCaretList = None
182
183	if any(t.table.MarkAttachClassDef for t in m.tables):
184		classDefs = {}
185		for table in m.tables:
186			if table.table.MarkAttachClassDef:
187				classDefs.update(table.table.MarkAttachClassDef.classDefs)
188		self.table.MarkAttachClassDef = otTables.MarkAttachClassDef()
189		self.table.MarkAttachClassDef.classDefs = classDefs
190	else:
191		self.table.MarkAttachClassDef = None
192
193	if any(t.table.GlyphClassDef for t in m.tables):
194		classDefs = {}
195		for table in m.tables:
196			if table.table.GlyphClassDef:
197				classDefs.update(table.table.GlyphClassDef.classDefs)
198		self.table.GlyphClassDef = otTables.GlyphClassDef()
199		self.table.GlyphClassDef.classDefs = classDefs
200	else:
201		self.table.GlyphClassDef = None
202
203	if any(t.table.AttachList for t in m.tables):
204		glyphs = []
205		attachPoints = []
206		for table in m.tables:
207			if table.table.AttachList:
208				glyphs.extend(table.table.AttachList.Coverage.glyphs)
209				attachPoints.extend(table.table.AttachList.AttachPoint)
210		coverage = otTables.Coverage()
211		coverage.glyphs = glyphs
212		attachList = otTables.AttachList()
213		attachList.Coverage = coverage
214		attachList.AttachPoint = attachPoints
215		attachList.GlyphCount = len(attachPoints)
216		self.table.AttachList = attachList
217	else:
218		self.table.AttachList = None
219
220	return True
221
222
223class Options(object):
224
225  class UnknownOptionError(Exception):
226    pass
227
228  _drop_tables_default = ['fpgm', 'prep', 'cvt ', 'gasp']
229  drop_tables = _drop_tables_default
230
231  def __init__(self, **kwargs):
232
233    self.set(**kwargs)
234
235  def set(self, **kwargs):
236    for k,v in kwargs.iteritems():
237      if not hasattr(self, k):
238        raise self.UnknownOptionError("Unknown option '%s'" % k)
239      setattr(self, k, v)
240
241  def parse_opts(self, argv, ignore_unknown=False):
242    ret = []
243    opts = {}
244    for a in argv:
245      orig_a = a
246      if not a.startswith('--'):
247        ret.append(a)
248        continue
249      a = a[2:]
250      i = a.find('=')
251      op = '='
252      if i == -1:
253        if a.startswith("no-"):
254          k = a[3:]
255          v = False
256        else:
257          k = a
258          v = True
259      else:
260        k = a[:i]
261        if k[-1] in "-+":
262          op = k[-1]+'='  # Ops is '-=' or '+=' now.
263          k = k[:-1]
264        v = a[i+1:]
265      k = k.replace('-', '_')
266      if not hasattr(self, k):
267        if ignore_unknown == True or k in ignore_unknown:
268          ret.append(orig_a)
269          continue
270        else:
271          raise self.UnknownOptionError("Unknown option '%s'" % a)
272
273      ov = getattr(self, k)
274      if isinstance(ov, bool):
275        v = bool(v)
276      elif isinstance(ov, int):
277        v = int(v)
278      elif isinstance(ov, list):
279        vv = v.split(',')
280        if vv == ['']:
281          vv = []
282        vv = [int(x, 0) if len(x) and x[0] in "0123456789" else x for x in vv]
283        if op == '=':
284          v = vv
285        elif op == '+=':
286          v = ov
287          v.extend(vv)
288        elif op == '-=':
289          v = ov
290          for x in vv:
291            if x in v:
292              v.remove(x)
293        else:
294          assert 0
295
296      opts[k] = v
297    self.set(**opts)
298
299    return ret
300
301
302class Merger:
303
304	def __init__(self, options=None, log=None):
305
306		if not log:
307			log = Logger()
308		if not options:
309			options = Options()
310
311		self.options = options
312		self.log = log
313
314	def merge(self, fontfiles):
315
316		mega = ttLib.TTFont()
317
318		#
319		# Settle on a mega glyph order.
320		#
321		fonts = [ttLib.TTFont(fontfile) for fontfile in fontfiles]
322		glyphOrders = [font.getGlyphOrder() for font in fonts]
323		megaGlyphOrder = self._mergeGlyphOrders(glyphOrders)
324		# Reload fonts and set new glyph names on them.
325		# TODO Is it necessary to reload font?  I think it is.  At least
326		# it's safer, in case tables were loaded to provide glyph names.
327		fonts = [ttLib.TTFont(fontfile) for fontfile in fontfiles]
328		for font,glyphOrder in zip(fonts, glyphOrders):
329			font.setGlyphOrder(glyphOrder)
330		mega.setGlyphOrder(megaGlyphOrder)
331
332		allTags = reduce(set.union, (font.keys() for font in fonts), set())
333		allTags.remove('GlyphOrder')
334		for tag in allTags:
335
336			if tag in self.options.drop_tables:
337				self.log("Dropping '%s'." % tag)
338				continue
339
340			clazz = ttLib.getTableClass(tag)
341
342			if not hasattr(clazz, 'merge'):
343				self.log("Don't know how to merge '%s', dropped." % tag)
344				continue
345
346			# TODO For now assume all fonts have the same tables.
347			self.tables = [font[tag] for font in fonts]
348			table = clazz(tag)
349			if table.merge (self):
350				mega[tag] = table
351				self.log("Merged '%s'." % tag)
352			else:
353				self.log("Dropped '%s'.  No need to merge explicitly." % tag)
354			self.log.lapse("merge '%s'" % tag)
355			del self.tables
356
357		return mega
358
359	def _mergeGlyphOrders(self, glyphOrders):
360		"""Modifies passed-in glyphOrders to reflect new glyph names."""
361		# Simply append font index to the glyph name for now.
362		mega = []
363		for n,glyphOrder in enumerate(glyphOrders):
364			for i,glyphName in enumerate(glyphOrder):
365				glyphName += "#" + `n`
366				glyphOrder[i] = glyphName
367				mega.append(glyphName)
368		return mega
369
370
371class Logger(object):
372
373  def __init__(self, verbose=False, xml=False, timing=False):
374    self.verbose = verbose
375    self.xml = xml
376    self.timing = timing
377    self.last_time = self.start_time = time.time()
378
379  def parse_opts(self, argv):
380    argv = argv[:]
381    for v in ['verbose', 'xml', 'timing']:
382      if "--"+v in argv:
383        setattr(self, v, True)
384        argv.remove("--"+v)
385    return argv
386
387  def __call__(self, *things):
388    if not self.verbose:
389      return
390    print ' '.join(str(x) for x in things)
391
392  def lapse(self, *things):
393    if not self.timing:
394      return
395    new_time = time.time()
396    print "Took %0.3fs to %s" %(new_time - self.last_time,
397                                 ' '.join(str(x) for x in things))
398    self.last_time = new_time
399
400  def font(self, font, file=sys.stdout):
401    if not self.xml:
402      return
403    from fontTools.misc import xmlWriter
404    writer = xmlWriter.XMLWriter(file)
405    font.disassembleInstructions = False  # Work around ttLib bug
406    for tag in font.keys():
407      writer.begintag(tag)
408      writer.newline()
409      font[tag].toXML(writer, font)
410      writer.endtag(tag)
411      writer.newline()
412
413
414__all__ = [
415  'Options',
416  'Merger',
417  'Logger',
418  'main'
419]
420
421def main(args):
422
423	log = Logger()
424	args = log.parse_opts(args)
425
426	options = Options()
427	args = options.parse_opts(args)
428
429	if len(args) < 1:
430		print >>sys.stderr, "usage: pyftmerge font..."
431		sys.exit(1)
432
433	merger = Merger(options=options, log=log)
434	font = merger.merge(args)
435	outfile = 'merged.ttf'
436	font.save(outfile)
437	log.lapse("compile and save font")
438
439	log.last_time = log.start_time
440	log.lapse("make one with everything(TOTAL TIME)")
441
442if __name__ == "__main__":
443	main(sys.argv[1:])
444