merge.py revision 92fd5665772996966aaa0709462ebae5a72d4e19
1# Copyright 2013 Google, Inc. All Rights Reserved.
2#
3# Google Author(s): Behdad Esfahbod, Roozbeh Pournader
4
5"""Font merger.
6"""
7
8from __future__ import print_function, division
9from fontTools.misc.py23 import *
10from fontTools import ttLib, cffLib
11from fontTools.ttLib.tables import otTables, _h_e_a_d
12from fontTools.ttLib.tables.DefaultTable import DefaultTable
13from functools import reduce
14import sys
15import time
16import operator
17
18
19def _add_method(*clazzes, **kwargs):
20	"""Returns a decorator function that adds a new method to one or
21	more classes."""
22	def wrapper(method):
23		for clazz in clazzes:
24			if not kwargs.get('allowDefaultTable', False):
25				assert clazz != DefaultTable, 'Oops, table class not found.'
26			assert method.__name__ not in clazz.__dict__, \
27				"Oops, class '%s' has method '%s'." % (clazz.__name__,
28								       method.__name__)
29			setattr(clazz, method.__name__, method)
30		return None
31	return wrapper
32
33# General utility functions for merging values from different fonts
34
35def equal(lst):
36	t = iter(lst)
37	first = next(t)
38	assert all(item == first for item in t)
39	return first
40
41def first(lst):
42	return next(iter(lst))
43
44def recalculate(lst):
45	return NotImplemented
46
47def current_time(lst):
48	return int(time.time() - _h_e_a_d.mac_epoch_diff)
49
50def bitwise_or(lst):
51	return reduce(operator.or_, lst)
52
53def avg_int(lst):
54	lst = list(lst)
55	return sum(lst) // len(lst)
56
57def nonnone(func):
58	"""Returns a filter func that when called with a list,
59	only calls func on the non-None items of the list, and
60	only so if there's at least one non-None item in the
61	list.  Otherwise returns None."""
62
63	def wrapper(lst):
64		items = [item for item in lst if item is not None]
65		return func(items) if items else None
66
67	return wrapper
68
69def implemented(func):
70	"""Returns a filter func that when called with a list,
71	only calls func on the non-NotImplemented items of the list,
72	and only so if there's at least one item remaining.
73	Otherwise returns NotImplemented."""
74
75	def wrapper(lst):
76		items = [item for item in lst if item is not NotImplemented]
77		return func(items) if items else NotImplemented
78
79	return wrapper
80
81def sumLists(lst):
82	l = []
83	for item in lst:
84		l.extend(item)
85	return l
86
87def sumDicts(lst):
88	d = {}
89	for item in lst:
90		d.update(item)
91	return d
92
93
94@_add_method(DefaultTable, allowDefaultTable=True)
95def merge(self, m, tables):
96	if not hasattr(self, 'mergeMap'):
97		m.log("Don't know how to merge '%s'." % self.tableTag)
98		return NotImplemented
99
100	return m.mergeObjects(self, self.mergeMap, tables)
101
102ttLib.getTableClass('maxp').mergeMap = {
103	'*': max,
104	'tableTag': equal,
105	'tableVersion': equal,
106	'numGlyphs': sum,
107	'maxStorage': max, # FIXME: may need to be changed to sum
108	'maxFunctionDefs': sum,
109	'maxInstructionDefs': sum,
110	# TODO When we correctly merge hinting data, update these values:
111	# maxFunctionDefs, maxInstructionDefs, maxSizeOfInstructions
112}
113
114ttLib.getTableClass('head').mergeMap = {
115	'tableTag': equal,
116	'tableVersion': max,
117	'fontRevision': max,
118	'checkSumAdjustment': lambda lst: 0, # We need *something* here
119	'magicNumber': equal,
120	'flags': first, # FIXME: replace with bit-sensitive code
121	'unitsPerEm': equal,
122	'created': current_time,
123	'modified': current_time,
124	'xMin': min,
125	'yMin': min,
126	'xMax': max,
127	'yMax': max,
128	'macStyle': first,
129	'lowestRecPPEM': max,
130	'fontDirectionHint': lambda lst: 2,
131	'indexToLocFormat': recalculate,
132	'glyphDataFormat': equal,
133}
134
135ttLib.getTableClass('hhea').mergeMap = {
136	'*': equal,
137	'tableTag': equal,
138	'tableVersion': max,
139	'ascent': max,
140	'descent': min,
141	'lineGap': max,
142	'advanceWidthMax': max,
143	'minLeftSideBearing': min,
144	'minRightSideBearing': min,
145	'xMaxExtent': max,
146	'caretSlopeRise': first, # FIXME
147	'caretSlopeRun': first, # FIXME
148	'caretOffset': first, # FIXME
149	'numberOfHMetrics': recalculate,
150}
151
152ttLib.getTableClass('OS/2').mergeMap = {
153	'*': first,
154	'tableTag': equal,
155	'version': max,
156	'xAvgCharWidth': avg_int, # Apparently fontTools doesn't recalc this
157	'fsType': first, # FIXME
158	'panose': first, # FIXME?
159	'ulUnicodeRange1': bitwise_or,
160	'ulUnicodeRange2': bitwise_or,
161	'ulUnicodeRange3': bitwise_or,
162	'ulUnicodeRange4': bitwise_or,
163	'fsFirstCharIndex': min,
164	'fsLastCharIndex': max,
165	'sTypoAscender': max,
166	'sTypoDescender': min,
167	'sTypoLineGap': max,
168	'usWinAscent': max,
169	'usWinDescent': max,
170	'ulCodePageRange1': bitwise_or,
171	'ulCodePageRange2': bitwise_or,
172	'usMaxContex': max,
173	# TODO version 5
174}
175
176ttLib.getTableClass('post').mergeMap = {
177	'*': first,
178	'tableTag': equal,
179	'formatType': max,
180	'isFixedPitch': min,
181	'minMemType42': max,
182	'maxMemType42': lambda lst: 0,
183	'minMemType1': max,
184	'maxMemType1': lambda lst: 0,
185	'mapping': implemented(sumDicts),
186	'extraNames': lambda lst: [][:],
187}
188
189ttLib.getTableClass('vmtx').mergeMap = ttLib.getTableClass('hmtx').mergeMap = {
190	'tableTag': equal,
191	'metrics': sumDicts,
192}
193
194ttLib.getTableClass('loca').mergeMap = {
195	'*': recalculate,
196	'tableTag': equal,
197}
198
199ttLib.getTableClass('glyf').mergeMap = {
200	'tableTag': equal,
201	'glyphs': sumDicts,
202	'glyphOrder': sumLists,
203}
204
205@_add_method(ttLib.getTableClass('glyf'))
206def merge(self, m, tables):
207	for table in tables:
208		for g in table.glyphs.values():
209			# Drop hints for now, since we don't remap
210			# functions / CVT values.
211			g.removeHinting()
212			# Expand composite glyphs to load their
213			# composite glyph names.
214			if g.isComposite():
215				g.expand(table)
216	return DefaultTable.merge(self, m, tables)
217
218ttLib.getTableClass('prep').mergeMap = NotImplemented
219ttLib.getTableClass('fpgm').mergeMap = NotImplemented
220ttLib.getTableClass('cvt ').mergeMap = NotImplemented
221
222@_add_method(ttLib.getTableClass('cmap'))
223def merge(self, m, tables):
224	# TODO Handle format=14.
225	cmapTables = [t for table in tables for t in table.tables
226		      if t.platformID == 3 and t.platEncID in [1, 10]]
227	# TODO Better handle format-4 and format-12 coexisting in same font.
228	# TODO Insert both a format-4 and format-12 if needed.
229	module = ttLib.getTableModule('cmap')
230	assert all(t.format in [4, 12] for t in cmapTables)
231	format = max(t.format for t in cmapTables)
232	cmapTable = module.cmap_classes[format](format)
233	cmapTable.cmap = {}
234	cmapTable.platformID = 3
235	cmapTable.platEncID = max(t.platEncID for t in cmapTables)
236	cmapTable.language = 0
237	for table in cmapTables:
238		# TODO handle duplicates.
239		cmapTable.cmap.update(table.cmap)
240	self.tableVersion = 0
241	self.tables = [cmapTable]
242	self.numSubTables = len(self.tables)
243	return self
244
245@_add_method(ttLib.getTableClass('GDEF'))
246def merge(self, m, tables):
247	self.table = otTables.GDEF()
248	self.table.Version = 1.0 # TODO version 1.2...
249
250	if any(t.table.LigCaretList for t in tables):
251		glyphs = []
252		ligGlyphs = []
253		for table in tables:
254			if table.table.LigCaretList:
255				glyphs.extend(table.table.LigCaretList.Coverage.glyphs)
256				ligGlyphs.extend(table.table.LigCaretList.LigGlyph)
257		coverage = otTables.Coverage()
258		coverage.glyphs = glyphs
259		ligCaretList = otTables.LigCaretList()
260		ligCaretList.Coverage = coverage
261		ligCaretList.LigGlyph = ligGlyphs
262		ligCaretList.GlyphCount = len(ligGlyphs)
263		self.table.LigCaretList = ligCaretList
264	else:
265		self.table.LigCaretList = None
266
267	if any(t.table.MarkAttachClassDef for t in tables):
268		classDefs = {}
269		for table in tables:
270			if table.table.MarkAttachClassDef:
271				classDefs.update(table.table.MarkAttachClassDef.classDefs)
272		self.table.MarkAttachClassDef = otTables.MarkAttachClassDef()
273		self.table.MarkAttachClassDef.classDefs = classDefs
274	else:
275		self.table.MarkAttachClassDef = None
276
277	if any(t.table.GlyphClassDef for t in tables):
278		classDefs = {}
279		for table in tables:
280			if table.table.GlyphClassDef:
281				classDefs.update(table.table.GlyphClassDef.classDefs)
282		self.table.GlyphClassDef = otTables.GlyphClassDef()
283		self.table.GlyphClassDef.classDefs = classDefs
284	else:
285		self.table.GlyphClassDef = None
286
287	if any(t.table.AttachList for t in tables):
288		glyphs = []
289		attachPoints = []
290		for table in tables:
291			if table.table.AttachList:
292				glyphs.extend(table.table.AttachList.Coverage.glyphs)
293				attachPoints.extend(table.table.AttachList.AttachPoint)
294		coverage = otTables.Coverage()
295		coverage.glyphs = glyphs
296		attachList = otTables.AttachList()
297		attachList.Coverage = coverage
298		attachList.AttachPoint = attachPoints
299		attachList.GlyphCount = len(attachPoints)
300		self.table.AttachList = attachList
301	else:
302		self.table.AttachList = None
303
304	return self
305
306
307class Options(object):
308
309  class UnknownOptionError(Exception):
310    pass
311
312  def __init__(self, **kwargs):
313
314    self.set(**kwargs)
315
316  def set(self, **kwargs):
317    for k,v in kwargs.items():
318      if not hasattr(self, k):
319        raise self.UnknownOptionError("Unknown option '%s'" % k)
320      setattr(self, k, v)
321
322  def parse_opts(self, argv, ignore_unknown=False):
323    ret = []
324    opts = {}
325    for a in argv:
326      orig_a = a
327      if not a.startswith('--'):
328        ret.append(a)
329        continue
330      a = a[2:]
331      i = a.find('=')
332      op = '='
333      if i == -1:
334        if a.startswith("no-"):
335          k = a[3:]
336          v = False
337        else:
338          k = a
339          v = True
340      else:
341        k = a[:i]
342        if k[-1] in "-+":
343          op = k[-1]+'='  # Ops is '-=' or '+=' now.
344          k = k[:-1]
345        v = a[i+1:]
346      k = k.replace('-', '_')
347      if not hasattr(self, k):
348        if ignore_unknown == True or k in ignore_unknown:
349          ret.append(orig_a)
350          continue
351        else:
352          raise self.UnknownOptionError("Unknown option '%s'" % a)
353
354      ov = getattr(self, k)
355      if isinstance(ov, bool):
356        v = bool(v)
357      elif isinstance(ov, int):
358        v = int(v)
359      elif isinstance(ov, list):
360        vv = v.split(',')
361        if vv == ['']:
362          vv = []
363        vv = [int(x, 0) if len(x) and x[0] in "0123456789" else x for x in vv]
364        if op == '=':
365          v = vv
366        elif op == '+=':
367          v = ov
368          v.extend(vv)
369        elif op == '-=':
370          v = ov
371          for x in vv:
372            if x in v:
373              v.remove(x)
374        else:
375          assert 0
376
377      opts[k] = v
378    self.set(**opts)
379
380    return ret
381
382
383class Merger(object):
384
385	def __init__(self, options=None, log=None):
386
387		if not log:
388			log = Logger()
389		if not options:
390			options = Options()
391
392		self.options = options
393		self.log = log
394
395	def merge(self, fontfiles):
396
397		mega = ttLib.TTFont()
398
399		#
400		# Settle on a mega glyph order.
401		#
402		fonts = [ttLib.TTFont(fontfile) for fontfile in fontfiles]
403		glyphOrders = [font.getGlyphOrder() for font in fonts]
404		megaGlyphOrder = self._mergeGlyphOrders(glyphOrders)
405		# Reload fonts and set new glyph names on them.
406		# TODO Is it necessary to reload font?  I think it is.  At least
407		# it's safer, in case tables were loaded to provide glyph names.
408		fonts = [ttLib.TTFont(fontfile) for fontfile in fontfiles]
409		for font,glyphOrder in zip(fonts, glyphOrders):
410			font.setGlyphOrder(glyphOrder)
411		mega.setGlyphOrder(megaGlyphOrder)
412
413		allTags = reduce(set.union, (list(font.keys()) for font in fonts), set())
414		allTags.remove('GlyphOrder')
415		for tag in allTags:
416
417			clazz = ttLib.getTableClass(tag)
418
419			tables = [font[tag] if font.has_key(tag) else NotImplemented for font in fonts]
420			table = clazz(tag).merge(self, tables)
421			if table is not NotImplemented and table is not False:
422				mega[tag] = table
423				self.log("Merged '%s'." % tag)
424			else:
425				self.log("Dropped '%s'." % tag)
426			self.log.lapse("merge '%s'" % tag)
427
428		return mega
429
430	def _mergeGlyphOrders(self, glyphOrders):
431		"""Modifies passed-in glyphOrders to reflect new glyph names.
432		Returns glyphOrder for the merged font."""
433		# Simply append font index to the glyph name for now.
434		# TODO Even this simplistic numbering can result in conflicts.
435		# But then again, we have to improve this soon anyway.
436		mega = []
437		for n,glyphOrder in enumerate(glyphOrders):
438			for i,glyphName in enumerate(glyphOrder):
439				glyphName += "#" + repr(n)
440				glyphOrder[i] = glyphName
441				mega.append(glyphName)
442		return mega
443
444	def mergeObjects(self, returnTable, logic, tables):
445		# Right now we don't use self at all.  Will use in the future
446		# for options and logging.
447
448		if logic is NotImplemented:
449			return NotImplemented
450
451		allKeys = set.union(set(), *(vars(table).keys() for table in tables if table is not NotImplemented))
452		for key in allKeys:
453			try:
454				mergeLogic = logic[key]
455			except KeyError:
456				try:
457					mergeLogic = logic['*']
458				except KeyError:
459					raise Exception("Don't know how to merge key %s of class %s" %
460							(key, returnTable.__class__.__name__))
461			if mergeLogic is NotImplemented:
462				continue
463			value = mergeLogic(getattr(table, key, NotImplemented) for table in tables)
464			if value is not NotImplemented:
465				setattr(returnTable, key, value)
466
467		return returnTable
468
469
470class Logger(object):
471
472  def __init__(self, verbose=False, xml=False, timing=False):
473    self.verbose = verbose
474    self.xml = xml
475    self.timing = timing
476    self.last_time = self.start_time = time.time()
477
478  def parse_opts(self, argv):
479    argv = argv[:]
480    for v in ['verbose', 'xml', 'timing']:
481      if "--"+v in argv:
482        setattr(self, v, True)
483        argv.remove("--"+v)
484    return argv
485
486  def __call__(self, *things):
487    if not self.verbose:
488      return
489    print(' '.join(str(x) for x in things))
490
491  def lapse(self, *things):
492    if not self.timing:
493      return
494    new_time = time.time()
495    print("Took %0.3fs to %s" %(new_time - self.last_time,
496                                 ' '.join(str(x) for x in things)))
497    self.last_time = new_time
498
499  def font(self, font, file=sys.stdout):
500    if not self.xml:
501      return
502    from fontTools.misc import xmlWriter
503    writer = xmlWriter.XMLWriter(file)
504    font.disassembleInstructions = False  # Work around ttLib bug
505    for tag in font.keys():
506      writer.begintag(tag)
507      writer.newline()
508      font[tag].toXML(writer, font)
509      writer.endtag(tag)
510      writer.newline()
511
512
513__all__ = [
514  'Options',
515  'Merger',
516  'Logger',
517  'main'
518]
519
520def main(args):
521
522	log = Logger()
523	args = log.parse_opts(args)
524
525	options = Options()
526	args = options.parse_opts(args)
527
528	if len(args) < 1:
529		print("usage: pyftmerge font...", file=sys.stderr)
530		sys.exit(1)
531
532	merger = Merger(options=options, log=log)
533	font = merger.merge(args)
534	outfile = 'merged.ttf'
535	font.save(outfile)
536	log.lapse("compile and save font")
537
538	log.last_time = log.start_time
539	log.lapse("make one with everything(TOTAL TIME)")
540
541if __name__ == "__main__":
542	main(sys.argv[1:])
543