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