merge.py revision 0bf4f561da7972ab90f6bee87fa30923e201adf5
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
13
14def _add_method(*clazzes):
15	"""Returns a decorator function that adds a new method to one or
16	more classes."""
17	def wrapper(method):
18		for clazz in clazzes:
19			assert clazz.__name__ != 'DefaultTable', 'Oops, table class not found.'
20			assert not hasattr(clazz, method.func_name), \
21				"Oops, class '%s' has method '%s'." % (clazz.__name__,
22								       method.func_name)
23		setattr(clazz, method.func_name, method)
24		return None
25	return wrapper
26
27
28@_add_method(fontTools.ttLib.getTableClass('maxp'))
29def merge(self, m):
30	# TODO When we correctly merge hinting data, update these values:
31	# maxFunctionDefs, maxInstructionDefs, maxSizeOfInstructions
32	# TODO Assumes that all tables have format 1.0; safe assumption.
33	for key in set(sum((vars(table).keys() for table in m.tables), [])):
34			setattr(self, key, max(getattr(table, key) for table in m.tables))
35	return True
36
37@_add_method(fontTools.ttLib.getTableClass('head'))
38def merge(self, m):
39	# TODO Check that unitsPerEm are the same.
40	# TODO Use bitwise ops for flags, macStyle, fontDirectionHint
41	minMembers = ['xMin', 'yMin']
42	# Negate some members
43	for key in minMembers:
44		for table in m.tables:
45			setattr(table, key, -getattr(table, key))
46	# Get max over members
47	for key in set(sum((vars(table).keys() for table in m.tables), [])):
48		setattr(self, key, max(getattr(table, key) for table in m.tables))
49	# Negate them back
50	for key in minMembers:
51		for table in m.tables:
52			setattr(table, key, -getattr(table, key))
53		setattr(self, key, -getattr(self, key))
54	return True
55
56@_add_method(fontTools.ttLib.getTableClass('hhea'))
57def merge(self, m):
58	# TODO Check that ascent, descent, slope, etc are the same.
59	minMembers = ['descent', 'minLeftSideBearing', 'minRightSideBearing']
60	# Negate some members
61	for key in minMembers:
62		for table in m.tables:
63			setattr(table, key, -getattr(table, key))
64	# Get max over members
65	for key in set(sum((vars(table).keys() for table in m.tables), [])):
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(fontTools.ttLib.getTableClass('OS/2'))
75def merge(self, m):
76	# TODO Check that weight/width/subscript/superscript/etc are the same.
77	# TODO Bitwise ops for UnicodeRange/CodePageRange.
78	# TODO Pretty much all fields generated here have bogus values.
79	# Get max over members
80	for key in set(sum((vars(table).keys() for table in m.tables), [])):
81		setattr(self, key, max(getattr(table, key) for table in m.tables))
82	return True
83
84@_add_method(fontTools.ttLib.getTableClass('post'))
85def merge(self, m):
86	# TODO Check that italicAngle, underlinePosition, underlineThickness are the same.
87	minMembers = ['underlinePosition', 'minMemType42', 'minMemType1']
88	# Negate some members
89	for key in minMembers:
90		for table in m.tables:
91			setattr(table, key, -getattr(table, key))
92	# Get max over members
93	keys = set(sum((vars(table).keys() for table in m.tables), []))
94	if 'mapping' in keys:
95		keys.remove('mapping')
96	keys.remove('extraNames')
97	for key in keys:
98		setattr(self, key, max(getattr(table, key) for table in m.tables))
99	# Negate them back
100	for key in minMembers:
101		for table in m.tables:
102			setattr(table, key, -getattr(table, key))
103		setattr(self, key, -getattr(self, key))
104	self.mapping = {}
105	for table in m.tables:
106		if hasattr(table, 'mapping'):
107			self.mapping.update(table.mapping)
108	self.extraNames = []
109	return True
110
111@_add_method(fontTools.ttLib.getTableClass('vmtx'),
112             fontTools.ttLib.getTableClass('hmtx'))
113def merge(self, m):
114	self.metrics = {}
115	for table in m.tables:
116		self.metrics.update(table.metrics)
117	return True
118
119@_add_method(fontTools.ttLib.getTableClass('loca'))
120def merge(self, m):
121	return True # Will be computed automatically
122
123@_add_method(fontTools.ttLib.getTableClass('glyf'))
124def merge(self, m):
125	self.glyphs = {}
126	for table in m.tables:
127		self.glyphs.update(table.glyphs)
128	# TODO Drop hints?
129	return True
130
131@_add_method(fontTools.ttLib.getTableClass('prep'),
132	     fontTools.ttLib.getTableClass('fpgm'),
133	     fontTools.ttLib.getTableClass('cvt '))
134def merge(self, m):
135	return False # Will be computed automatically
136
137@_add_method(fontTools.ttLib.getTableClass('cmap'))
138def merge(self, m):
139	# TODO Handle format=14.
140	cmapTables = [t for table in m.tables for t in table.tables
141		      if t.platformID == 3 and t.platEncID in [1, 10]]
142	# TODO Better handle format-4 and format-12 coexisting in same font.
143	# TODO Insert both a format-4 and format-12 if needed.
144	module = fontTools.ttLib.getTableModule('cmap')
145	assert all(t.format in [4, 12] for t in cmapTables)
146	format = max(t.format for t in cmapTables)
147	cmapTable = module.cmap_classes[format](format)
148	cmapTable.cmap = {}
149	cmapTable.platformID = 3
150	cmapTable.platEncID = max(t.platEncID for t in cmapTables)
151	cmapTable.language = 0
152	for table in cmapTables:
153		# TODO handle duplicates.
154		cmapTable.cmap.update(table.cmap)
155	self.tableVersion = 0
156	self.tables = [cmapTable]
157	self.numSubTables = len(self.tables)
158	return True
159
160
161class Options(object):
162
163  class UnknownOptionError(Exception):
164    pass
165
166  _drop_tables_default = ['fpgm', 'prep', 'cvt ', 'gasp']
167  drop_tables = _drop_tables_default
168
169  def __init__(self, **kwargs):
170
171    self.set(**kwargs)
172
173  def set(self, **kwargs):
174    for k,v in kwargs.iteritems():
175      if not hasattr(self, k):
176        raise self.UnknownOptionError("Unknown option '%s'" % k)
177      setattr(self, k, v)
178
179  def parse_opts(self, argv, ignore_unknown=False):
180    ret = []
181    opts = {}
182    for a in argv:
183      orig_a = a
184      if not a.startswith('--'):
185        ret.append(a)
186        continue
187      a = a[2:]
188      i = a.find('=')
189      op = '='
190      if i == -1:
191        if a.startswith("no-"):
192          k = a[3:]
193          v = False
194        else:
195          k = a
196          v = True
197      else:
198        k = a[:i]
199        if k[-1] in "-+":
200          op = k[-1]+'='  # Ops is '-=' or '+=' now.
201          k = k[:-1]
202        v = a[i+1:]
203      k = k.replace('-', '_')
204      if not hasattr(self, k):
205        if ignore_unknown == True or k in ignore_unknown:
206          ret.append(orig_a)
207          continue
208        else:
209          raise self.UnknownOptionError("Unknown option '%s'" % a)
210
211      ov = getattr(self, k)
212      if isinstance(ov, bool):
213        v = bool(v)
214      elif isinstance(ov, int):
215        v = int(v)
216      elif isinstance(ov, list):
217        vv = v.split(',')
218        if vv == ['']:
219          vv = []
220        vv = [int(x, 0) if len(x) and x[0] in "0123456789" else x for x in vv]
221        if op == '=':
222          v = vv
223        elif op == '+=':
224          v = ov
225          v.extend(vv)
226        elif op == '-=':
227          v = ov
228          for x in vv:
229            if x in v:
230              v.remove(x)
231        else:
232          assert 0
233
234      opts[k] = v
235    self.set(**opts)
236
237    return ret
238
239
240class Merger:
241
242	def __init__(self, options=None, log=None):
243
244		if not log:
245			log = Logger()
246		if not options:
247			options = Options()
248
249		self.options = options
250		self.log = log
251
252	def merge(self, fontfiles):
253
254		mega = ttLib.TTFont()
255
256		#
257		# Settle on a mega glyph order.
258		#
259		fonts = [ttLib.TTFont(fontfile) for fontfile in fontfiles]
260		glyphOrders = [font.getGlyphOrder() for font in fonts]
261		megaGlyphOrder = self._mergeGlyphOrders(glyphOrders)
262		# Reload fonts and set new glyph names on them.
263		# TODO Is it necessary to reload font?  I think it is.  At least
264		# it's safer, in case tables were loaded to provide glyph names.
265		fonts = [ttLib.TTFont(fontfile) for fontfile in fontfiles]
266		map(ttLib.TTFont.setGlyphOrder, fonts, glyphOrders)
267		mega.setGlyphOrder(megaGlyphOrder)
268
269		allTags = set(sum([font.keys() for font in fonts], []))
270		allTags.remove('GlyphOrder')
271		for tag in allTags:
272
273			if tag in self.options.drop_tables:
274				self.log("Dropping '%s'." % tag)
275				continue
276
277			clazz = ttLib.getTableClass(tag)
278
279			if not hasattr(clazz, 'merge'):
280				self.log("Don't know how to merge '%s', dropped." % tag)
281				continue
282
283			# TODO For now assume all fonts have the same tables.
284			self.tables = [font[tag] for font in fonts]
285			table = clazz(tag)
286			if table.merge (self):
287				mega[tag] = table
288				self.log("Merged '%s'." % tag)
289			else:
290				self.log("Dropped '%s'.  No need to merge explicitly." % tag)
291			self.log.lapse("merge '%s'" % tag)
292			del self.tables
293
294		return mega
295
296	def _mergeGlyphOrders(self, glyphOrders):
297		"""Modifies passed-in glyphOrders to reflect new glyph names."""
298		# Simply append font index to the glyph name for now.
299		mega = []
300		for n,glyphOrder in enumerate(glyphOrders):
301			for i,glyphName in enumerate(glyphOrder):
302				glyphName += "#" + `n`
303				glyphOrder[i] = glyphName
304				mega.append(glyphName)
305		return mega
306
307
308class Logger(object):
309
310  def __init__(self, verbose=False, xml=False, timing=False):
311    self.verbose = verbose
312    self.xml = xml
313    self.timing = timing
314    self.last_time = self.start_time = time.time()
315
316  def parse_opts(self, argv):
317    argv = argv[:]
318    for v in ['verbose', 'xml', 'timing']:
319      if "--"+v in argv:
320        setattr(self, v, True)
321        argv.remove("--"+v)
322    return argv
323
324  def __call__(self, *things):
325    if not self.verbose:
326      return
327    print ' '.join(str(x) for x in things)
328
329  def lapse(self, *things):
330    if not self.timing:
331      return
332    new_time = time.time()
333    print "Took %0.3fs to %s" %(new_time - self.last_time,
334                                 ' '.join(str(x) for x in things))
335    self.last_time = new_time
336
337  def font(self, font, file=sys.stdout):
338    if not self.xml:
339      return
340    from fontTools.misc import xmlWriter
341    writer = xmlWriter.XMLWriter(file)
342    font.disassembleInstructions = False  # Work around ttLib bug
343    for tag in font.keys():
344      writer.begintag(tag)
345      writer.newline()
346      font[tag].toXML(writer, font)
347      writer.endtag(tag)
348      writer.newline()
349
350
351__all__ = [
352  'Options',
353  'Merger',
354  'Logger',
355  'main'
356]
357
358def main(args):
359
360	log = Logger()
361	args = log.parse_opts(args)
362
363	options = Options()
364	args = options.parse_opts(args)
365
366	if len(args) < 1:
367		print >>sys.stderr, "usage: pyftmerge font..."
368		sys.exit(1)
369
370	merger = Merger(options=options, log=log)
371	font = merger.merge(args)
372	outfile = 'merged.ttf'
373	font.save(outfile)
374	log.lapse("compile and save font")
375
376	log.last_time = log.start_time
377	log.lapse("make one with everything(TOTAL TIME)")
378
379if __name__ == "__main__":
380	main(sys.argv[1:])
381