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