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