merge.py revision 6baf26ea74247a0bed5f0b52d13bc3ef1c931204
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
34def equal(lst):
35	t = iter(lst)
36	first = next(t)
37	assert all(item == first for item in t)
38	return first
39
40def first(lst):
41	return next(iter(lst))
42
43def recalculate(lst):
44	# Just return the first value, assume will be recalculated when saved
45	return first(lst)
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 ignore(lst):
54	assert False, "This function should not be called."
55
56def maybenone(func):
57	"""Returns a filter func that when called with a list,
58	only calls func on the non-None items of the list, and
59	only so if there's at least one non-None item in the
60	list."""
61
62	def wrapper(lst):
63		items = [item for item in lst if item is not None]
64		return func(items) if items else None
65
66	return wrapper
67
68def sumLists(lst):
69	l = []
70	for item in lst:
71		l.extend(item)
72	return l
73
74def sumDicts(lst):
75	d = {}
76	for item in lst:
77		d.update(item)
78	return d
79
80
81@_add_method(DefaultTable, allowDefaultTable=True)
82def merge(self, m, tables):
83	if not hasattr(self, 'mergeMap'):
84		m.log("Don't know how to merge '%s'." % self.tableTag)
85		return False
86
87	m.mergeObjects(self, self.mergeMap, tables)
88	return True
89
90ttLib.getTableClass('maxp').mergeMap = {
91	'*': max,
92	'tableTag': equal,
93	'tableVersion': equal,
94	'numGlyphs': sum,
95	'maxStorage': max, # FIXME: may need to be changed to sum
96	'maxFunctionDefs': sum,
97	'maxInstructionDefs': sum,
98	# TODO When we correctly merge hinting data, update these values:
99	# maxFunctionDefs, maxInstructionDefs, maxSizeOfInstructions
100}
101
102ttLib.getTableClass('head').mergeMap = {
103	'tableTag': equal,
104	'tableVersion': max,
105	'fontRevision': max,
106	'checkSumAdjustment': recalculate,
107	'magicNumber': equal,
108	'flags': first, # FIXME: replace with bit-sensitive code
109	'unitsPerEm': equal,
110	'created': current_time,
111	'modified': current_time,
112	'xMin': min,
113	'yMin': min,
114	'xMax': max,
115	'yMax': max,
116	'macStyle': first,
117	'lowestRecPPEM': max,
118	'fontDirectionHint': lambda lst: 2,
119	'indexToLocFormat': recalculate,
120	'glyphDataFormat': equal,
121}
122
123ttLib.getTableClass('hhea').mergeMap = {
124	'*': equal,
125	'tableTag': equal,
126	'tableVersion': max,
127	'ascent': max,
128	'descent': min,
129	'lineGap': max,
130	'advanceWidthMax': max,
131	'minLeftSideBearing': min,
132	'minRightSideBearing': min,
133	'xMaxExtent': max,
134	'caretSlopeRise': first, # FIXME
135	'caretSlopeRun': first, # FIXME
136	'caretOffset': first, # FIXME
137	'numberOfHMetrics': recalculate,
138}
139
140ttLib.getTableClass('OS/2').mergeMap = {
141	'*': first,
142	'tableTag': equal,
143	'version': max,
144	'xAvgCharWidth': recalculate,
145	'fsType': first, # FIXME
146	'panose': first, # FIXME?
147	'ulUnicodeRange1': bitwise_or,
148	'ulUnicodeRange2': bitwise_or,
149	'ulUnicodeRange3': bitwise_or,
150	'ulUnicodeRange4': bitwise_or,
151	'fsFirstCharIndex': min,
152	'fsLastCharIndex': max,
153	'sTypoAscender': max,
154	'sTypoDescender': min,
155	'sTypoLineGap': max,
156	'usWinAscent': max,
157	'usWinDescent': max,
158	'ulCodePageRange1': bitwise_or,
159	'ulCodePageRange2': bitwise_or,
160	'usMaxContex': max,
161	# TODO version 5
162}
163
164ttLib.getTableClass('post').mergeMap = {
165	'*': first,
166	'tableTag': equal,
167	'formatType': max,
168	'isFixedPitch': min,
169	'minMemType42': max,
170	'maxMemType42': lambda lst: 0,
171	'minMemType1': max,
172	'maxMemType1': lambda lst: 0,
173	'mapping': ignore,
174	'extraNames': ignore,
175}
176@_add_method(ttLib.getTableClass('post'))
177def merge(self, m, tables):
178	DefaultTable.merge(self, m, tables)
179	self.mapping = {}
180	for table in tables:
181		if hasattr(table, 'mapping'):
182			self.mapping.update(table.mapping)
183	self.extraNames = []
184	return True
185
186ttLib.getTableClass('vmtx').mergeMap = ttLib.getTableClass('hmtx').mergeMap = {
187	'tableTag': equal,
188	'metrics': sumDicts,
189}
190
191ttLib.getTableClass('loca').mergeMap = {
192	'*': ignore,
193	'tableTag': equal,
194}
195
196ttLib.getTableClass('glyf').mergeMap = {
197	'tableTag': equal,
198	'glyphs': sumDicts,
199	'glyphOrder': sumLists,
200}
201
202@_add_method(ttLib.getTableClass('glyf'))
203def merge(self, m, tables):
204	for table in tables:
205		for g in table.glyphs.values():
206			# Drop hints for now, since we don't remap
207			# functions / CVT values.
208			g.removeHinting()
209			# Expand composite glyphs to load their
210			# composite glyph names.
211			if g.isComposite():
212				g.expand(table)
213	DefaultTable.merge(self, m, tables)
214	return True
215
216@_add_method(ttLib.getTableClass('prep'),
217	     ttLib.getTableClass('fpgm'),
218	     ttLib.getTableClass('cvt '))
219def merge(self, m):
220	return False # TODO We don't merge hinting data currently.
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 True
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 True
305
306
307class Options(object):
308
309  class UnknownOptionError(Exception):
310    pass
311
312  _drop_tables_default = ['fpgm', 'prep', 'cvt ', 'gasp']
313  drop_tables = _drop_tables_default
314
315  def __init__(self, **kwargs):
316
317    self.set(**kwargs)
318
319  def set(self, **kwargs):
320    for k,v in kwargs.items():
321      if not hasattr(self, k):
322        raise self.UnknownOptionError("Unknown option '%s'" % k)
323      setattr(self, k, v)
324
325  def parse_opts(self, argv, ignore_unknown=False):
326    ret = []
327    opts = {}
328    for a in argv:
329      orig_a = a
330      if not a.startswith('--'):
331        ret.append(a)
332        continue
333      a = a[2:]
334      i = a.find('=')
335      op = '='
336      if i == -1:
337        if a.startswith("no-"):
338          k = a[3:]
339          v = False
340        else:
341          k = a
342          v = True
343      else:
344        k = a[:i]
345        if k[-1] in "-+":
346          op = k[-1]+'='  # Ops is '-=' or '+=' now.
347          k = k[:-1]
348        v = a[i+1:]
349      k = k.replace('-', '_')
350      if not hasattr(self, k):
351        if ignore_unknown == True or k in ignore_unknown:
352          ret.append(orig_a)
353          continue
354        else:
355          raise self.UnknownOptionError("Unknown option '%s'" % a)
356
357      ov = getattr(self, k)
358      if isinstance(ov, bool):
359        v = bool(v)
360      elif isinstance(ov, int):
361        v = int(v)
362      elif isinstance(ov, list):
363        vv = v.split(',')
364        if vv == ['']:
365          vv = []
366        vv = [int(x, 0) if len(x) and x[0] in "0123456789" else x for x in vv]
367        if op == '=':
368          v = vv
369        elif op == '+=':
370          v = ov
371          v.extend(vv)
372        elif op == '-=':
373          v = ov
374          for x in vv:
375            if x in v:
376              v.remove(x)
377        else:
378          assert 0
379
380      opts[k] = v
381    self.set(**opts)
382
383    return ret
384
385
386class Merger(object):
387
388	def __init__(self, options=None, log=None):
389
390		if not log:
391			log = Logger()
392		if not options:
393			options = Options()
394
395		self.options = options
396		self.log = log
397
398	def merge(self, fontfiles):
399
400		mega = ttLib.TTFont()
401
402		#
403		# Settle on a mega glyph order.
404		#
405		fonts = [ttLib.TTFont(fontfile) for fontfile in fontfiles]
406		glyphOrders = [font.getGlyphOrder() for font in fonts]
407		megaGlyphOrder = self._mergeGlyphOrders(glyphOrders)
408		# Reload fonts and set new glyph names on them.
409		# TODO Is it necessary to reload font?  I think it is.  At least
410		# it's safer, in case tables were loaded to provide glyph names.
411		fonts = [ttLib.TTFont(fontfile) for fontfile in fontfiles]
412		for font,glyphOrder in zip(fonts, glyphOrders):
413			font.setGlyphOrder(glyphOrder)
414		mega.setGlyphOrder(megaGlyphOrder)
415
416		allTags = reduce(set.union, (list(font.keys()) for font in fonts), set())
417		allTags.remove('GlyphOrder')
418		for tag in allTags:
419
420			if tag in self.options.drop_tables:
421				self.log("Dropping '%s'." % tag)
422				continue
423
424			clazz = ttLib.getTableClass(tag)
425
426			# TODO For now assume all fonts have the same tables.
427			tables = [font[tag] for font in fonts]
428			table = clazz(tag)
429			if table.merge (self, tables):
430				mega[tag] = table
431				self.log("Merged '%s'." % tag)
432			else:
433				self.log("Dropped '%s'." % tag)
434			self.log.lapse("merge '%s'" % tag)
435
436		return mega
437
438	def _mergeGlyphOrders(self, glyphOrders):
439		"""Modifies passed-in glyphOrders to reflect new glyph names.
440		Returns glyphOrder for the merged font."""
441		# Simply append font index to the glyph name for now.
442		# TODO Even this simplistic numbering can result in conflicts.
443		# But then again, we have to improve this soon anyway.
444		mega = []
445		for n,glyphOrder in enumerate(glyphOrders):
446			for i,glyphName in enumerate(glyphOrder):
447				glyphName += "#" + repr(n)
448				glyphOrder[i] = glyphName
449				mega.append(glyphName)
450		return mega
451
452	def mergeObjects(self, returnTable, logic, tables):
453		allKeys = set.union(set(), *(vars(table).keys() for table in tables))
454		for key in allKeys:
455			try:
456				mergeLogic = logic[key]
457			except KeyError:
458				try:
459					mergeLogic = logic['*']
460				except KeyError:
461					raise Exception("Don't know how to merge key %s of class %s" %
462							(key, returnTable.__class__.__name__))
463			if mergeLogic == ignore:
464				continue
465			key_value = mergeLogic(getattr(table, key) for table in tables)
466			setattr(returnTable, key, key_value)
467
468
469class Logger(object):
470
471  def __init__(self, verbose=False, xml=False, timing=False):
472    self.verbose = verbose
473    self.xml = xml
474    self.timing = timing
475    self.last_time = self.start_time = time.time()
476
477  def parse_opts(self, argv):
478    argv = argv[:]
479    for v in ['verbose', 'xml', 'timing']:
480      if "--"+v in argv:
481        setattr(self, v, True)
482        argv.remove("--"+v)
483    return argv
484
485  def __call__(self, *things):
486    if not self.verbose:
487      return
488    print(' '.join(str(x) for x in things))
489
490  def lapse(self, *things):
491    if not self.timing:
492      return
493    new_time = time.time()
494    print("Took %0.3fs to %s" %(new_time - self.last_time,
495                                 ' '.join(str(x) for x in things)))
496    self.last_time = new_time
497
498  def font(self, font, file=sys.stdout):
499    if not self.xml:
500      return
501    from fontTools.misc import xmlWriter
502    writer = xmlWriter.XMLWriter(file)
503    font.disassembleInstructions = False  # Work around ttLib bug
504    for tag in font.keys():
505      writer.begintag(tag)
506      writer.newline()
507      font[tag].toXML(writer, font)
508      writer.endtag(tag)
509      writer.newline()
510
511
512__all__ = [
513  'Options',
514  'Merger',
515  'Logger',
516  'main'
517]
518
519def main(args):
520
521	log = Logger()
522	args = log.parse_opts(args)
523
524	options = Options()
525	args = options.parse_opts(args)
526
527	if len(args) < 1:
528		print("usage: pyftmerge font...", file=sys.stderr)
529		sys.exit(1)
530
531	merger = Merger(options=options, log=log)
532	font = merger.merge(args)
533	outfile = 'merged.ttf'
534	font.save(outfile)
535	log.lapse("compile and save font")
536
537	log.last_time = log.start_time
538	log.lapse("make one with everything(TOTAL TIME)")
539
540if __name__ == "__main__":
541	main(sys.argv[1:])
542