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