merge.py revision 398770d51c3ae0e7c267ef2b315beae2b6aa2df8
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('loca').mergeMap = {
224	'*': recalculate,
225	'tableTag': equal,
226}
227
228ttLib.getTableClass('glyf').mergeMap = {
229	'tableTag': equal,
230	'glyphs': sumDicts,
231	'glyphOrder': sumLists,
232}
233
234@_add_method(ttLib.getTableClass('glyf'))
235def merge(self, m, tables):
236	for table in tables:
237		for g in table.glyphs.values():
238			# Drop hints for now, since we don't remap
239			# functions / CVT values.
240			g.removeHinting()
241			# Expand composite glyphs to load their
242			# composite glyph names.
243			if g.isComposite():
244				g.expand(table)
245	return DefaultTable.merge(self, m, tables)
246
247ttLib.getTableClass('prep').mergeMap = NotImplemented
248ttLib.getTableClass('fpgm').mergeMap = NotImplemented
249ttLib.getTableClass('cvt ').mergeMap = NotImplemented
250
251@_add_method(ttLib.getTableClass('cmap'))
252def merge(self, m, tables):
253	# TODO Handle format=14.
254	cmapTables = [t for table in tables for t in table.tables
255		      if t.platformID == 3 and t.platEncID in [1, 10]]
256	# TODO Better handle format-4 and format-12 coexisting in same font.
257	# TODO Insert both a format-4 and format-12 if needed.
258	module = ttLib.getTableModule('cmap')
259	assert all(t.format in [4, 12] for t in cmapTables)
260	format = max(t.format for t in cmapTables)
261	cmapTable = module.cmap_classes[format](format)
262	cmapTable.cmap = {}
263	cmapTable.platformID = 3
264	cmapTable.platEncID = max(t.platEncID for t in cmapTables)
265	cmapTable.language = 0
266	for table in cmapTables:
267		# TODO handle duplicates.
268		cmapTable.cmap.update(table.cmap)
269	self.tableVersion = 0
270	self.tables = [cmapTable]
271	self.numSubTables = len(self.tables)
272	return self
273
274
275otTables.ScriptList.mergeMap = {
276	'ScriptCount': sum,
277	'ScriptRecord': sumLists,
278}
279
280otTables.FeatureList.mergeMap = {
281	'FeatureCount': sum,
282	'FeatureRecord': sumLists,
283}
284
285otTables.LookupList.mergeMap = {
286	'LookupCount': sum,
287	'Lookup': sumLists,
288}
289
290otTables.Coverage.mergeMap = {
291	'glyphs': sumLists,
292}
293
294otTables.ClassDef.mergeMap = {
295	'classDefs': sumDicts,
296}
297
298otTables.LigCaretList.mergeMap = {
299	'Coverage': mergeObjects,
300	'LigGlyphCount': sum,
301	'LigGlyph': sumLists,
302}
303
304otTables.AttachList.mergeMap = {
305	'Coverage': mergeObjects,
306	'GlyphCount': sum,
307	'AttachPoint': sumLists,
308}
309
310# XXX Renumber MarkFilterSets of lookups
311otTables.MarkGlyphSetsDef.mergeMap = {
312	'MarkSetTableFormat': equal,
313	'MarkSetCount': sum,
314	'Coverage': sumLists,
315}
316
317otTables.GDEF.mergeMap = {
318	'*': mergeObjects,
319	'Version': max,
320}
321
322otTables.GSUB.mergeMap = otTables.GPOS.mergeMap = {
323	'*': mergeObjects,
324	'Version': max,
325}
326
327ttLib.getTableClass('GDEF').mergeMap = \
328ttLib.getTableClass('GSUB').mergeMap = \
329ttLib.getTableClass('GPOS').mergeMap = \
330ttLib.getTableClass('BASE').mergeMap = \
331ttLib.getTableClass('JSTF').mergeMap = \
332ttLib.getTableClass('MATH').mergeMap = \
333{
334	'tableTag': equal,
335	'table': mergeObjects,
336}
337
338
339@_add_method(otTables.Feature)
340def mapLookups(self, lookupMap):
341	self.LookupListIndex = [lookupMap[i] for i in self.LookupListIndex]
342
343@_add_method(otTables.FeatureList)
344def mapLookups(self, lookupMap):
345	for f in self.FeatureRecord:
346		if not f or not f.Feature: continue
347		f.Feature.mapLookups(lookupMap)
348
349@_add_method(otTables.DefaultLangSys,
350             otTables.LangSys)
351def mapFeatures(self, featureMap):
352	self.FeatureIndex = [featureMap[i] for i in self.FeatureIndex]
353	if self.ReqFeatureIndex != 65535:
354		self.ReqFeatureIndex = featureMap[self.ReqFeatureIndex]
355
356@_add_method(otTables.Script)
357def mapFeatures(self, featureMap):
358	if self.DefaultLangSys:
359		self.DefaultLangSys.mapFeatures(featureMap)
360	for l in self.LangSysRecord:
361		if not l or not l.LangSys: continue
362		l.LangSys.mapFeatures(featureMap)
363
364@_add_method(otTables.ScriptList)
365def mapFeatures(self, featureMap):
366	for s in self.ScriptRecord:
367		if not s or not s.Script: continue
368		s.Script.mapFeatures(featureMap)
369
370
371class Options(object):
372
373  class UnknownOptionError(Exception):
374    pass
375
376  def __init__(self, **kwargs):
377
378    self.set(**kwargs)
379
380  def set(self, **kwargs):
381    for k,v in kwargs.items():
382      if not hasattr(self, k):
383        raise self.UnknownOptionError("Unknown option '%s'" % k)
384      setattr(self, k, v)
385
386  def parse_opts(self, argv, ignore_unknown=False):
387    ret = []
388    opts = {}
389    for a in argv:
390      orig_a = a
391      if not a.startswith('--'):
392        ret.append(a)
393        continue
394      a = a[2:]
395      i = a.find('=')
396      op = '='
397      if i == -1:
398        if a.startswith("no-"):
399          k = a[3:]
400          v = False
401        else:
402          k = a
403          v = True
404      else:
405        k = a[:i]
406        if k[-1] in "-+":
407          op = k[-1]+'='  # Ops is '-=' or '+=' now.
408          k = k[:-1]
409        v = a[i+1:]
410      k = k.replace('-', '_')
411      if not hasattr(self, k):
412        if ignore_unknown == True or k in ignore_unknown:
413          ret.append(orig_a)
414          continue
415        else:
416          raise self.UnknownOptionError("Unknown option '%s'" % a)
417
418      ov = getattr(self, k)
419      if isinstance(ov, bool):
420        v = bool(v)
421      elif isinstance(ov, int):
422        v = int(v)
423      elif isinstance(ov, list):
424        vv = v.split(',')
425        if vv == ['']:
426          vv = []
427        vv = [int(x, 0) if len(x) and x[0] in "0123456789" else x for x in vv]
428        if op == '=':
429          v = vv
430        elif op == '+=':
431          v = ov
432          v.extend(vv)
433        elif op == '-=':
434          v = ov
435          for x in vv:
436            if x in v:
437              v.remove(x)
438        else:
439          assert 0
440
441      opts[k] = v
442    self.set(**opts)
443
444    return ret
445
446
447class Merger(object):
448
449	def __init__(self, options=None, log=None):
450
451		if not log:
452			log = Logger()
453		if not options:
454			options = Options()
455
456		self.options = options
457		self.log = log
458
459	def merge(self, fontfiles):
460
461		mega = ttLib.TTFont()
462
463		#
464		# Settle on a mega glyph order.
465		#
466		fonts = [ttLib.TTFont(fontfile) for fontfile in fontfiles]
467		glyphOrders = [font.getGlyphOrder() for font in fonts]
468		megaGlyphOrder = self._mergeGlyphOrders(glyphOrders)
469		# Reload fonts and set new glyph names on them.
470		# TODO Is it necessary to reload font?  I think it is.  At least
471		# it's safer, in case tables were loaded to provide glyph names.
472		fonts = [ttLib.TTFont(fontfile) for fontfile in fontfiles]
473		for font,glyphOrder in zip(fonts, glyphOrders):
474			font.setGlyphOrder(glyphOrder)
475		mega.setGlyphOrder(megaGlyphOrder)
476
477		for font in fonts:
478			self._preMerge(font)
479
480		allTags = reduce(set.union, (list(font.keys()) for font in fonts), set())
481		allTags.remove('GlyphOrder')
482		for tag in allTags:
483
484			clazz = ttLib.getTableClass(tag)
485
486			tables = [font.get(tag, NotImplemented) for font in fonts]
487			table = clazz(tag).merge(self, tables)
488			if table is not NotImplemented and table is not False:
489				mega[tag] = table
490				self.log("Merged '%s'." % tag)
491			else:
492				self.log("Dropped '%s'." % tag)
493			self.log.lapse("merge '%s'" % tag)
494
495		self._postMerge(mega)
496
497		return mega
498
499	def _mergeGlyphOrders(self, glyphOrders):
500		"""Modifies passed-in glyphOrders to reflect new glyph names.
501		Returns glyphOrder for the merged font."""
502		# Simply append font index to the glyph name for now.
503		# TODO Even this simplistic numbering can result in conflicts.
504		# But then again, we have to improve this soon anyway.
505		mega = []
506		for n,glyphOrder in enumerate(glyphOrders):
507			for i,glyphName in enumerate(glyphOrder):
508				glyphName += "#" + repr(n)
509				glyphOrder[i] = glyphName
510				mega.append(glyphName)
511		return mega
512
513	def mergeObjects(self, returnTable, logic, tables):
514		# Right now we don't use self at all.  Will use in the future
515		# for options and logging.
516
517		if logic is NotImplemented:
518			return NotImplemented
519
520		allKeys = set.union(set(), *(vars(table).keys() for table in tables if table is not NotImplemented))
521		for key in allKeys:
522			try:
523				mergeLogic = logic[key]
524			except KeyError:
525				try:
526					mergeLogic = logic['*']
527				except KeyError:
528					raise Exception("Don't know how to merge key %s of class %s" %
529							(key, returnTable.__class__.__name__))
530			if mergeLogic is NotImplemented:
531				continue
532			value = mergeLogic(getattr(table, key, NotImplemented) for table in tables)
533			if value is not NotImplemented:
534				setattr(returnTable, key, value)
535
536		return returnTable
537
538	def _preMerge(self, font):
539
540		GDEF = font.get('GDEF')
541		GSUB = font.get('GSUB')
542		GPOS = font.get('GPOS')
543
544		for t in [GSUB, GPOS]:
545			if not t: continue
546
547			if t.table.LookupList and t.table.FeatureList:
548				lookupMap = dict(enumerate(t.table.LookupList.Lookup))
549				t.table.FeatureList.mapLookups(lookupMap)
550
551			if t.table.FeatureList and t.table.ScriptList:
552				featureMap = dict(enumerate(t.table.FeatureList.FeatureRecord))
553				t.table.ScriptList.mapFeatures(featureMap)
554
555		# TODO GDEF/Lookup MarkFilteringSets
556		# TODO FeatureParams nameIDs
557
558	def _postMerge(self, font):
559
560		GDEF = font.get('GDEF')
561		GSUB = font.get('GSUB')
562		GPOS = font.get('GPOS')
563
564		for t in [GSUB, GPOS]:
565			if not t: continue
566
567			if t.table.LookupList and t.table.FeatureList:
568				lookupMap = dict((v,i) for i,v in enumerate(t.table.LookupList.Lookup))
569				t.table.FeatureList.mapLookups(lookupMap)
570
571			if t.table.FeatureList and t.table.ScriptList:
572				featureMap = dict((v,i) for i,v in enumerate(t.table.FeatureList.FeatureRecord))
573				t.table.ScriptList.mapFeatures(featureMap)
574
575		# TODO GDEF/Lookup MarkFilteringSets
576		# TODO FeatureParams nameIDs
577
578
579class Logger(object):
580
581  def __init__(self, verbose=False, xml=False, timing=False):
582    self.verbose = verbose
583    self.xml = xml
584    self.timing = timing
585    self.last_time = self.start_time = time.time()
586
587  def parse_opts(self, argv):
588    argv = argv[:]
589    for v in ['verbose', 'xml', 'timing']:
590      if "--"+v in argv:
591        setattr(self, v, True)
592        argv.remove("--"+v)
593    return argv
594
595  def __call__(self, *things):
596    if not self.verbose:
597      return
598    print(' '.join(str(x) for x in things))
599
600  def lapse(self, *things):
601    if not self.timing:
602      return
603    new_time = time.time()
604    print("Took %0.3fs to %s" %(new_time - self.last_time,
605                                 ' '.join(str(x) for x in things)))
606    self.last_time = new_time
607
608  def font(self, font, file=sys.stdout):
609    if not self.xml:
610      return
611    from fontTools.misc import xmlWriter
612    writer = xmlWriter.XMLWriter(file)
613    font.disassembleInstructions = False  # Work around ttLib bug
614    for tag in font.keys():
615      writer.begintag(tag)
616      writer.newline()
617      font[tag].toXML(writer, font)
618      writer.endtag(tag)
619      writer.newline()
620
621
622__all__ = [
623  'Options',
624  'Merger',
625  'Logger',
626  'main'
627]
628
629def main(args):
630
631	log = Logger()
632	args = log.parse_opts(args)
633
634	options = Options()
635	args = options.parse_opts(args)
636
637	if len(args) < 1:
638		print("usage: pyftmerge font...", file=sys.stderr)
639		sys.exit(1)
640
641	merger = Merger(options=options, log=log)
642	font = merger.merge(args)
643	outfile = 'merged.ttf'
644	font.save(outfile)
645	log.lapse("compile and save font")
646
647	log.last_time = log.start_time
648	log.lapse("make one with everything(TOTAL TIME)")
649
650if __name__ == "__main__":
651	main(sys.argv[1:])
652