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.misc.timeTools import timestampNow
11from fontTools import ttLib, cffLib
12from fontTools.ttLib.tables import otTables, _h_e_a_d
13from fontTools.ttLib.tables.DefaultTable import DefaultTable
14from fontTools.misc.loggingTools import Timer
15from fontTools.pens.recordingPen import DecomposingRecordingPen
16from functools import reduce
17import sys
18import time
19import operator
20import logging
21
22
23log = logging.getLogger("fontTools.merge")
24timer = Timer(logger=logging.getLogger(__name__+".timer"), level=logging.INFO)
25
26
27def _add_method(*clazzes, **kwargs):
28	"""Returns a decorator function that adds a new method to one or
29	more classes."""
30	allowDefault = kwargs.get('allowDefaultTable', False)
31	def wrapper(method):
32		done = []
33		for clazz in clazzes:
34			if clazz in done: continue # Support multiple names of a clazz
35			done.append(clazz)
36			assert allowDefault or clazz != DefaultTable, 'Oops, table class not found.'
37			assert method.__name__ not in clazz.__dict__, \
38				"Oops, class '%s' has method '%s'." % (clazz.__name__, method.__name__)
39			setattr(clazz, method.__name__, method)
40		return None
41	return wrapper
42
43# General utility functions for merging values from different fonts
44
45def equal(lst):
46	lst = list(lst)
47	t = iter(lst)
48	first = next(t)
49	assert all(item == first for item in t), "Expected all items to be equal: %s" % lst
50	return first
51
52def first(lst):
53	return next(iter(lst))
54
55def recalculate(lst):
56	return NotImplemented
57
58def current_time(lst):
59	return timestampNow()
60
61def bitwise_and(lst):
62	return reduce(operator.and_, lst)
63
64def bitwise_or(lst):
65	return reduce(operator.or_, lst)
66
67def avg_int(lst):
68	lst = list(lst)
69	return sum(lst) // len(lst)
70
71def onlyExisting(func):
72	"""Returns a filter func that when called with a list,
73	only calls func on the non-NotImplemented items of the list,
74	and only so if there's at least one item remaining.
75	Otherwise returns NotImplemented."""
76
77	def wrapper(lst):
78		items = [item for item in lst if item is not NotImplemented]
79		return func(items) if items else NotImplemented
80
81	return wrapper
82
83def sumLists(lst):
84	l = []
85	for item in lst:
86		l.extend(item)
87	return l
88
89def sumDicts(lst):
90	d = {}
91	for item in lst:
92		d.update(item)
93	return d
94
95def mergeObjects(lst):
96	lst = [item for item in lst if item is not NotImplemented]
97	if not lst:
98		return NotImplemented
99	lst = [item for item in lst if item is not None]
100	if not lst:
101		return None
102
103	clazz = lst[0].__class__
104	assert all(type(item) == clazz for item in lst), lst
105
106	logic = clazz.mergeMap
107	returnTable = clazz()
108	returnDict = {}
109
110	allKeys = set.union(set(), *(vars(table).keys() for table in lst))
111	for key in allKeys:
112		try:
113			mergeLogic = logic[key]
114		except KeyError:
115			try:
116				mergeLogic = logic['*']
117			except KeyError:
118				raise Exception("Don't know how to merge key %s of class %s" %
119						(key, clazz.__name__))
120		if mergeLogic is NotImplemented:
121			continue
122		value = mergeLogic(getattr(table, key, NotImplemented) for table in lst)
123		if value is not NotImplemented:
124			returnDict[key] = value
125
126	returnTable.__dict__ = returnDict
127
128	return returnTable
129
130def mergeBits(bitmap):
131
132	def wrapper(lst):
133		lst = list(lst)
134		returnValue = 0
135		for bitNumber in range(bitmap['size']):
136			try:
137				mergeLogic = bitmap[bitNumber]
138			except KeyError:
139				try:
140					mergeLogic = bitmap['*']
141				except KeyError:
142					raise Exception("Don't know how to merge bit %s" % bitNumber)
143			shiftedBit = 1 << bitNumber
144			mergedValue = mergeLogic(bool(item & shiftedBit) for item in lst)
145			returnValue |= mergedValue << bitNumber
146		return returnValue
147
148	return wrapper
149
150
151@_add_method(DefaultTable, allowDefaultTable=True)
152def merge(self, m, tables):
153	if not hasattr(self, 'mergeMap'):
154		log.info("Don't know how to merge '%s'.", self.tableTag)
155		return NotImplemented
156
157	logic = self.mergeMap
158
159	if isinstance(logic, dict):
160		return m.mergeObjects(self, self.mergeMap, tables)
161	else:
162		return logic(tables)
163
164
165ttLib.getTableClass('maxp').mergeMap = {
166	'*': max,
167	'tableTag': equal,
168	'tableVersion': equal,
169	'numGlyphs': sum,
170	'maxStorage': first,
171	'maxFunctionDefs': first,
172	'maxInstructionDefs': first,
173	# TODO When we correctly merge hinting data, update these values:
174	# maxFunctionDefs, maxInstructionDefs, maxSizeOfInstructions
175}
176
177headFlagsMergeBitMap = {
178	'size': 16,
179	'*': bitwise_or,
180	1: bitwise_and, # Baseline at y = 0
181	2: bitwise_and, # lsb at x = 0
182	3: bitwise_and, # Force ppem to integer values. FIXME?
183	5: bitwise_and, # Font is vertical
184	6: lambda bit: 0, # Always set to zero
185	11: bitwise_and, # Font data is 'lossless'
186	13: bitwise_and, # Optimized for ClearType
187	14: bitwise_and, # Last resort font. FIXME? equal or first may be better
188	15: lambda bit: 0, # Always set to zero
189}
190
191ttLib.getTableClass('head').mergeMap = {
192	'tableTag': equal,
193	'tableVersion': max,
194	'fontRevision': max,
195	'checkSumAdjustment': lambda lst: 0, # We need *something* here
196	'magicNumber': equal,
197	'flags': mergeBits(headFlagsMergeBitMap),
198	'unitsPerEm': equal,
199	'created': current_time,
200	'modified': current_time,
201	'xMin': min,
202	'yMin': min,
203	'xMax': max,
204	'yMax': max,
205	'macStyle': first,
206	'lowestRecPPEM': max,
207	'fontDirectionHint': lambda lst: 2,
208	'indexToLocFormat': recalculate,
209	'glyphDataFormat': equal,
210}
211
212ttLib.getTableClass('hhea').mergeMap = {
213	'*': equal,
214	'tableTag': equal,
215	'tableVersion': max,
216	'ascent': max,
217	'descent': min,
218	'lineGap': max,
219	'advanceWidthMax': max,
220	'minLeftSideBearing': min,
221	'minRightSideBearing': min,
222	'xMaxExtent': max,
223	'caretSlopeRise': first,
224	'caretSlopeRun': first,
225	'caretOffset': first,
226	'numberOfHMetrics': recalculate,
227}
228
229ttLib.getTableClass('vhea').mergeMap = {
230	'*': equal,
231	'tableTag': equal,
232	'tableVersion': max,
233	'ascent': max,
234	'descent': min,
235	'lineGap': max,
236	'advanceHeightMax': max,
237	'minTopSideBearing': min,
238	'minBottomSideBearing': min,
239	'yMaxExtent': max,
240	'caretSlopeRise': first,
241	'caretSlopeRun': first,
242	'caretOffset': first,
243	'numberOfVMetrics': recalculate,
244}
245
246os2FsTypeMergeBitMap = {
247	'size': 16,
248	'*': lambda bit: 0,
249	1: bitwise_or, # no embedding permitted
250	2: bitwise_and, # allow previewing and printing documents
251	3: bitwise_and, # allow editing documents
252	8: bitwise_or, # no subsetting permitted
253	9: bitwise_or, # no embedding of outlines permitted
254}
255
256def mergeOs2FsType(lst):
257	lst = list(lst)
258	if all(item == 0 for item in lst):
259		return 0
260
261	# Compute least restrictive logic for each fsType value
262	for i in range(len(lst)):
263		# unset bit 1 (no embedding permitted) if either bit 2 or 3 is set
264		if lst[i] & 0x000C:
265			lst[i] &= ~0x0002
266		# set bit 2 (allow previewing) if bit 3 is set (allow editing)
267		elif lst[i] & 0x0008:
268			lst[i] |= 0x0004
269		# set bits 2 and 3 if everything is allowed
270		elif lst[i] == 0:
271			lst[i] = 0x000C
272
273	fsType = mergeBits(os2FsTypeMergeBitMap)(lst)
274	# unset bits 2 and 3 if bit 1 is set (some font is "no embedding")
275	if fsType & 0x0002:
276		fsType &= ~0x000C
277	return fsType
278
279
280ttLib.getTableClass('OS/2').mergeMap = {
281	'*': first,
282	'tableTag': equal,
283	'version': max,
284	'xAvgCharWidth': avg_int, # Apparently fontTools doesn't recalc this
285	'fsType': mergeOs2FsType, # Will be overwritten
286	'panose': first, # FIXME: should really be the first Latin font
287	'ulUnicodeRange1': bitwise_or,
288	'ulUnicodeRange2': bitwise_or,
289	'ulUnicodeRange3': bitwise_or,
290	'ulUnicodeRange4': bitwise_or,
291	'fsFirstCharIndex': min,
292	'fsLastCharIndex': max,
293	'sTypoAscender': max,
294	'sTypoDescender': min,
295	'sTypoLineGap': max,
296	'usWinAscent': max,
297	'usWinDescent': max,
298	# Version 2,3,4
299	'ulCodePageRange1': onlyExisting(bitwise_or),
300	'ulCodePageRange2': onlyExisting(bitwise_or),
301	'usMaxContex': onlyExisting(max),
302	# TODO version 5
303}
304
305@_add_method(ttLib.getTableClass('OS/2'))
306def merge(self, m, tables):
307	DefaultTable.merge(self, m, tables)
308	if self.version < 2:
309		# bits 8 and 9 are reserved and should be set to zero
310		self.fsType &= ~0x0300
311	if self.version >= 3:
312		# Only one of bits 1, 2, and 3 may be set. We already take
313		# care of bit 1 implications in mergeOs2FsType. So unset
314		# bit 2 if bit 3 is already set.
315		if self.fsType & 0x0008:
316			self.fsType &= ~0x0004
317	return self
318
319ttLib.getTableClass('post').mergeMap = {
320	'*': first,
321	'tableTag': equal,
322	'formatType': max,
323	'isFixedPitch': min,
324	'minMemType42': max,
325	'maxMemType42': lambda lst: 0,
326	'minMemType1': max,
327	'maxMemType1': lambda lst: 0,
328	'mapping': onlyExisting(sumDicts),
329	'extraNames': lambda lst: [],
330}
331
332ttLib.getTableClass('vmtx').mergeMap = ttLib.getTableClass('hmtx').mergeMap = {
333	'tableTag': equal,
334	'metrics': sumDicts,
335}
336
337ttLib.getTableClass('name').mergeMap = {
338	'tableTag': equal,
339	'names': first, # FIXME? Does mixing name records make sense?
340}
341
342ttLib.getTableClass('loca').mergeMap = {
343	'*': recalculate,
344	'tableTag': equal,
345}
346
347ttLib.getTableClass('glyf').mergeMap = {
348	'tableTag': equal,
349	'glyphs': sumDicts,
350	'glyphOrder': sumLists,
351}
352
353@_add_method(ttLib.getTableClass('glyf'))
354def merge(self, m, tables):
355	for i,table in enumerate(tables):
356		for g in table.glyphs.values():
357			if i:
358				# Drop hints for all but first font, since
359				# we don't map functions / CVT values.
360				g.removeHinting()
361			# Expand composite glyphs to load their
362			# composite glyph names.
363			if g.isComposite():
364				g.expand(table)
365	return DefaultTable.merge(self, m, tables)
366
367ttLib.getTableClass('prep').mergeMap = lambda self, lst: first(lst)
368ttLib.getTableClass('fpgm').mergeMap = lambda self, lst: first(lst)
369ttLib.getTableClass('cvt ').mergeMap = lambda self, lst: first(lst)
370ttLib.getTableClass('gasp').mergeMap = lambda self, lst: first(lst) # FIXME? Appears irreconcilable
371
372def _glyphsAreSame(glyphSet1, glyphSet2, glyph1, glyph2):
373	pen1 = DecomposingRecordingPen(glyphSet1)
374	pen2 = DecomposingRecordingPen(glyphSet2)
375	g1 = glyphSet1[glyph1]
376	g2 = glyphSet2[glyph2]
377	g1.draw(pen1)
378	g2.draw(pen2)
379	return (pen1.value == pen2.value and
380		g1.width == g2.width and
381		(not hasattr(g1, 'height') or g1.height == g2.height))
382
383# Valid (format, platformID, platEncID) triplets for cmap subtables containing
384# Unicode BMP-only and Unicode Full Repertoire semantics.
385# Cf. OpenType spec for "Platform specific encodings":
386# https://docs.microsoft.com/en-us/typography/opentype/spec/name
387class CmapUnicodePlatEncodings:
388	BMP = {(4, 3, 1), (4, 0, 3), (4, 0, 4), (4, 0, 6)}
389	FullRepertoire = {(12, 3, 10), (12, 0, 4), (12, 0, 6)}
390
391@_add_method(ttLib.getTableClass('cmap'))
392def merge(self, m, tables):
393	# TODO Handle format=14.
394	# Only merge format 4 and 12 Unicode subtables, ignores all other subtables
395	# If there is a format 12 table for the same font, ignore the format 4 table
396	cmapTables = []
397	for fontIdx,table in enumerate(tables):
398		format4 = None
399		format12 = None
400		for subtable in table.tables:
401			properties = (subtable.format, subtable.platformID, subtable.platEncID)
402			if properties in CmapUnicodePlatEncodings.BMP:
403				format4 = subtable
404			elif properties in CmapUnicodePlatEncodings.FullRepertoire:
405				format12 = subtable
406			else:
407				log.warning(
408					"Dropped cmap subtable from font [%s]:\t"
409					"format %2s, platformID %2s, platEncID %2s",
410					fontIdx, subtable.format, subtable.platformID, subtable.platEncID
411				)
412		if format12 is not None:
413			cmapTables.append((format12, fontIdx))
414		elif format4 is not None:
415			cmapTables.append((format4, fontIdx))
416
417	# Build a unicode mapping, then decide which format is needed to store it.
418	cmap = {}
419	fontIndexForGlyph = {}
420	glyphSets = [None for f in m.fonts] if hasattr(m, 'fonts') else None
421	for table,fontIdx in cmapTables:
422		# handle duplicates
423		for uni,gid in table.cmap.items():
424			oldgid = cmap.get(uni, None)
425			if oldgid is None:
426				cmap[uni] = gid
427				fontIndexForGlyph[gid] = fontIdx
428			elif oldgid != gid:
429				# Char previously mapped to oldgid, now to gid.
430				# Record, to fix up in GSUB 'locl' later.
431				if m.duplicateGlyphsPerFont[fontIdx].get(oldgid) is None:
432					if glyphSets is not None:
433						oldFontIdx = fontIndexForGlyph[oldgid]
434						for idx in (fontIdx, oldFontIdx):
435							if glyphSets[idx] is None:
436								glyphSets[idx] = m.fonts[idx].getGlyphSet()
437						if _glyphsAreSame(glyphSets[oldFontIdx], glyphSets[fontIdx], oldgid, gid):
438							continue
439					m.duplicateGlyphsPerFont[fontIdx][oldgid] = gid
440				elif m.duplicateGlyphsPerFont[fontIdx][oldgid] != gid:
441					# Char previously mapped to oldgid but oldgid is already remapped to a different
442					# gid, because of another Unicode character.
443					# TODO: Try harder to do something about these.
444					log.warning("Dropped mapping from codepoint %#06X to glyphId '%s'", uni, gid)
445
446	cmapBmpOnly = {uni: gid for uni,gid in cmap.items() if uni <= 0xFFFF}
447	self.tables = []
448	module = ttLib.getTableModule('cmap')
449	if len(cmapBmpOnly) != len(cmap):
450		# format-12 required.
451		cmapTable = module.cmap_classes[12](12)
452		cmapTable.platformID = 3
453		cmapTable.platEncID = 10
454		cmapTable.language = 0
455		cmapTable.cmap = cmap
456		self.tables.append(cmapTable)
457	# always create format-4
458	cmapTable = module.cmap_classes[4](4)
459	cmapTable.platformID = 3
460	cmapTable.platEncID = 1
461	cmapTable.language = 0
462	cmapTable.cmap = cmapBmpOnly
463	# ordered by platform then encoding
464	self.tables.insert(0, cmapTable)
465	self.tableVersion = 0
466	self.numSubTables = len(self.tables)
467	return self
468
469
470def mergeLookupLists(lst):
471	# TODO Do smarter merge.
472	return sumLists(lst)
473
474def mergeFeatures(lst):
475	assert lst
476	self = otTables.Feature()
477	self.FeatureParams = None
478	self.LookupListIndex = mergeLookupLists([l.LookupListIndex for l in lst if l.LookupListIndex])
479	self.LookupCount = len(self.LookupListIndex)
480	return self
481
482def mergeFeatureLists(lst):
483	d = {}
484	for l in lst:
485		for f in l:
486			tag = f.FeatureTag
487			if tag not in d:
488				d[tag] = []
489			d[tag].append(f.Feature)
490	ret = []
491	for tag in sorted(d.keys()):
492		rec = otTables.FeatureRecord()
493		rec.FeatureTag = tag
494		rec.Feature = mergeFeatures(d[tag])
495		ret.append(rec)
496	return ret
497
498def mergeLangSyses(lst):
499	assert lst
500
501	# TODO Support merging ReqFeatureIndex
502	assert all(l.ReqFeatureIndex == 0xFFFF for l in lst)
503
504	self = otTables.LangSys()
505	self.LookupOrder = None
506	self.ReqFeatureIndex = 0xFFFF
507	self.FeatureIndex = mergeFeatureLists([l.FeatureIndex for l in lst if l.FeatureIndex])
508	self.FeatureCount = len(self.FeatureIndex)
509	return self
510
511def mergeScripts(lst):
512	assert lst
513
514	if len(lst) == 1:
515		return lst[0]
516	langSyses = {}
517	for sr in lst:
518		for lsr in sr.LangSysRecord:
519			if lsr.LangSysTag not in langSyses:
520				langSyses[lsr.LangSysTag] = []
521			langSyses[lsr.LangSysTag].append(lsr.LangSys)
522	lsrecords = []
523	for tag, langSys_list in sorted(langSyses.items()):
524		lsr = otTables.LangSysRecord()
525		lsr.LangSys = mergeLangSyses(langSys_list)
526		lsr.LangSysTag = tag
527		lsrecords.append(lsr)
528
529	self = otTables.Script()
530	self.LangSysRecord = lsrecords
531	self.LangSysCount = len(lsrecords)
532	dfltLangSyses = [s.DefaultLangSys for s in lst if s.DefaultLangSys]
533	if dfltLangSyses:
534		self.DefaultLangSys = mergeLangSyses(dfltLangSyses)
535	else:
536		self.DefaultLangSys = None
537	return self
538
539def mergeScriptRecords(lst):
540	d = {}
541	for l in lst:
542		for s in l:
543			tag = s.ScriptTag
544			if tag not in d:
545				d[tag] = []
546			d[tag].append(s.Script)
547	ret = []
548	for tag in sorted(d.keys()):
549		rec = otTables.ScriptRecord()
550		rec.ScriptTag = tag
551		rec.Script = mergeScripts(d[tag])
552		ret.append(rec)
553	return ret
554
555otTables.ScriptList.mergeMap = {
556	'ScriptCount': lambda lst: None, # TODO
557	'ScriptRecord': mergeScriptRecords,
558}
559otTables.BaseScriptList.mergeMap = {
560	'BaseScriptCount': lambda lst: None, # TODO
561	# TODO: Merge duplicate entries
562	'BaseScriptRecord': lambda lst: sorted(sumLists(lst), key=lambda s: s.BaseScriptTag),
563}
564
565otTables.FeatureList.mergeMap = {
566	'FeatureCount': sum,
567	'FeatureRecord': lambda lst: sorted(sumLists(lst), key=lambda s: s.FeatureTag),
568}
569
570otTables.LookupList.mergeMap = {
571	'LookupCount': sum,
572	'Lookup': sumLists,
573}
574
575otTables.Coverage.mergeMap = {
576	'Format': min,
577	'glyphs': sumLists,
578}
579
580otTables.ClassDef.mergeMap = {
581	'Format': min,
582	'classDefs': sumDicts,
583}
584
585otTables.LigCaretList.mergeMap = {
586	'Coverage': mergeObjects,
587	'LigGlyphCount': sum,
588	'LigGlyph': sumLists,
589}
590
591otTables.AttachList.mergeMap = {
592	'Coverage': mergeObjects,
593	'GlyphCount': sum,
594	'AttachPoint': sumLists,
595}
596
597# XXX Renumber MarkFilterSets of lookups
598otTables.MarkGlyphSetsDef.mergeMap = {
599	'MarkSetTableFormat': equal,
600	'MarkSetCount': sum,
601	'Coverage': sumLists,
602}
603
604otTables.Axis.mergeMap = {
605	'*': mergeObjects,
606}
607
608# XXX Fix BASE table merging
609otTables.BaseTagList.mergeMap = {
610	'BaseTagCount': sum,
611	'BaselineTag': sumLists,
612}
613
614otTables.GDEF.mergeMap = \
615otTables.GSUB.mergeMap = \
616otTables.GPOS.mergeMap = \
617otTables.BASE.mergeMap = \
618otTables.JSTF.mergeMap = \
619otTables.MATH.mergeMap = \
620{
621	'*': mergeObjects,
622	'Version': max,
623}
624
625ttLib.getTableClass('GDEF').mergeMap = \
626ttLib.getTableClass('GSUB').mergeMap = \
627ttLib.getTableClass('GPOS').mergeMap = \
628ttLib.getTableClass('BASE').mergeMap = \
629ttLib.getTableClass('JSTF').mergeMap = \
630ttLib.getTableClass('MATH').mergeMap = \
631{
632	'tableTag': onlyExisting(equal), # XXX clean me up
633	'table': mergeObjects,
634}
635
636@_add_method(ttLib.getTableClass('GSUB'))
637def merge(self, m, tables):
638
639	assert len(tables) == len(m.duplicateGlyphsPerFont)
640	for i,(table,dups) in enumerate(zip(tables, m.duplicateGlyphsPerFont)):
641		if not dups: continue
642		assert (table is not None and table is not NotImplemented), "Have duplicates to resolve for font %d but no GSUB: %s" % (i + 1, dups)
643		synthFeature = None
644		synthLookup = None
645		for script in table.table.ScriptList.ScriptRecord:
646			if script.ScriptTag == 'DFLT': continue # XXX
647			for langsys in [script.Script.DefaultLangSys] + [l.LangSys for l in script.Script.LangSysRecord]:
648				if langsys is None: continue # XXX Create!
649				feature = [v for v in langsys.FeatureIndex if v.FeatureTag == 'locl']
650				assert len(feature) <= 1
651				if feature:
652					feature = feature[0]
653				else:
654					if not synthFeature:
655						synthFeature = otTables.FeatureRecord()
656						synthFeature.FeatureTag = 'locl'
657						f = synthFeature.Feature = otTables.Feature()
658						f.FeatureParams = None
659						f.LookupCount = 0
660						f.LookupListIndex = []
661						langsys.FeatureIndex.append(synthFeature)
662						langsys.FeatureIndex.sort(key=lambda v: v.FeatureTag)
663						table.table.FeatureList.FeatureRecord.append(synthFeature)
664						table.table.FeatureList.FeatureCount += 1
665					feature = synthFeature
666
667				if not synthLookup:
668					subtable = otTables.SingleSubst()
669					subtable.mapping = dups
670					synthLookup = otTables.Lookup()
671					synthLookup.LookupFlag = 0
672					synthLookup.LookupType = 1
673					synthLookup.SubTableCount = 1
674					synthLookup.SubTable = [subtable]
675					if table.table.LookupList is None:
676						# mtiLib uses None as default value for LookupList,
677						# while feaLib points to an empty array with count 0
678						# TODO: make them do the same
679						table.table.LookupList = otTables.LookupList()
680						table.table.LookupList.Lookup = []
681						table.table.LookupList.LookupCount = 0
682					table.table.LookupList.Lookup.append(synthLookup)
683					table.table.LookupList.LookupCount += 1
684
685				feature.Feature.LookupListIndex[:0] = [synthLookup]
686				feature.Feature.LookupCount += 1
687
688	DefaultTable.merge(self, m, tables)
689	return self
690
691@_add_method(otTables.SingleSubst,
692		otTables.MultipleSubst,
693		otTables.AlternateSubst,
694		otTables.LigatureSubst,
695		otTables.ReverseChainSingleSubst,
696		otTables.SinglePos,
697		otTables.PairPos,
698		otTables.CursivePos,
699		otTables.MarkBasePos,
700		otTables.MarkLigPos,
701		otTables.MarkMarkPos)
702def mapLookups(self, lookupMap):
703	pass
704
705# Copied and trimmed down from subset.py
706@_add_method(otTables.ContextSubst,
707		otTables.ChainContextSubst,
708		otTables.ContextPos,
709		otTables.ChainContextPos)
710def __merge_classify_context(self):
711
712	class ContextHelper(object):
713		def __init__(self, klass, Format):
714			if klass.__name__.endswith('Subst'):
715				Typ = 'Sub'
716				Type = 'Subst'
717			else:
718				Typ = 'Pos'
719				Type = 'Pos'
720			if klass.__name__.startswith('Chain'):
721				Chain = 'Chain'
722			else:
723				Chain = ''
724			ChainTyp = Chain+Typ
725
726			self.Typ = Typ
727			self.Type = Type
728			self.Chain = Chain
729			self.ChainTyp = ChainTyp
730
731			self.LookupRecord = Type+'LookupRecord'
732
733			if Format == 1:
734				self.Rule = ChainTyp+'Rule'
735				self.RuleSet = ChainTyp+'RuleSet'
736			elif Format == 2:
737				self.Rule = ChainTyp+'ClassRule'
738				self.RuleSet = ChainTyp+'ClassSet'
739
740	if self.Format not in [1, 2, 3]:
741		return None  # Don't shoot the messenger; let it go
742	if not hasattr(self.__class__, "__ContextHelpers"):
743		self.__class__.__ContextHelpers = {}
744	if self.Format not in self.__class__.__ContextHelpers:
745		helper = ContextHelper(self.__class__, self.Format)
746		self.__class__.__ContextHelpers[self.Format] = helper
747	return self.__class__.__ContextHelpers[self.Format]
748
749
750@_add_method(otTables.ContextSubst,
751		otTables.ChainContextSubst,
752		otTables.ContextPos,
753		otTables.ChainContextPos)
754def mapLookups(self, lookupMap):
755	c = self.__merge_classify_context()
756
757	if self.Format in [1, 2]:
758		for rs in getattr(self, c.RuleSet):
759			if not rs: continue
760			for r in getattr(rs, c.Rule):
761				if not r: continue
762				for ll in getattr(r, c.LookupRecord):
763					if not ll: continue
764					ll.LookupListIndex = lookupMap[ll.LookupListIndex]
765	elif self.Format == 3:
766		for ll in getattr(self, c.LookupRecord):
767			if not ll: continue
768			ll.LookupListIndex = lookupMap[ll.LookupListIndex]
769	else:
770		assert 0, "unknown format: %s" % self.Format
771
772@_add_method(otTables.ExtensionSubst,
773		otTables.ExtensionPos)
774def mapLookups(self, lookupMap):
775	if self.Format == 1:
776		self.ExtSubTable.mapLookups(lookupMap)
777	else:
778		assert 0, "unknown format: %s" % self.Format
779
780@_add_method(otTables.Lookup)
781def mapLookups(self, lookupMap):
782	for st in self.SubTable:
783		if not st: continue
784		st.mapLookups(lookupMap)
785
786@_add_method(otTables.LookupList)
787def mapLookups(self, lookupMap):
788	for l in self.Lookup:
789		if not l: continue
790		l.mapLookups(lookupMap)
791
792@_add_method(otTables.Feature)
793def mapLookups(self, lookupMap):
794	self.LookupListIndex = [lookupMap[i] for i in self.LookupListIndex]
795
796@_add_method(otTables.FeatureList)
797def mapLookups(self, lookupMap):
798	for f in self.FeatureRecord:
799		if not f or not f.Feature: continue
800		f.Feature.mapLookups(lookupMap)
801
802@_add_method(otTables.DefaultLangSys,
803		otTables.LangSys)
804def mapFeatures(self, featureMap):
805	self.FeatureIndex = [featureMap[i] for i in self.FeatureIndex]
806	if self.ReqFeatureIndex != 65535:
807		self.ReqFeatureIndex = featureMap[self.ReqFeatureIndex]
808
809@_add_method(otTables.Script)
810def mapFeatures(self, featureMap):
811	if self.DefaultLangSys:
812		self.DefaultLangSys.mapFeatures(featureMap)
813	for l in self.LangSysRecord:
814		if not l or not l.LangSys: continue
815		l.LangSys.mapFeatures(featureMap)
816
817@_add_method(otTables.ScriptList)
818def mapFeatures(self, featureMap):
819	for s in self.ScriptRecord:
820		if not s or not s.Script: continue
821		s.Script.mapFeatures(featureMap)
822
823
824class Options(object):
825
826	class UnknownOptionError(Exception):
827		pass
828
829	def __init__(self, **kwargs):
830
831		self.verbose = False
832		self.timing = False
833
834		self.set(**kwargs)
835
836	def set(self, **kwargs):
837		for k,v in kwargs.items():
838			if not hasattr(self, k):
839				raise self.UnknownOptionError("Unknown option '%s'" % k)
840			setattr(self, k, v)
841
842	def parse_opts(self, argv, ignore_unknown=[]):
843		ret = []
844		opts = {}
845		for a in argv:
846			orig_a = a
847			if not a.startswith('--'):
848				ret.append(a)
849				continue
850			a = a[2:]
851			i = a.find('=')
852			op = '='
853			if i == -1:
854				if a.startswith("no-"):
855					k = a[3:]
856					v = False
857				else:
858					k = a
859					v = True
860			else:
861				k = a[:i]
862				if k[-1] in "-+":
863					op = k[-1]+'='  # Ops is '-=' or '+=' now.
864					k = k[:-1]
865				v = a[i+1:]
866			k = k.replace('-', '_')
867			if not hasattr(self, k):
868				if ignore_unknown is True or k in ignore_unknown:
869					ret.append(orig_a)
870					continue
871				else:
872					raise self.UnknownOptionError("Unknown option '%s'" % a)
873
874			ov = getattr(self, k)
875			if isinstance(ov, bool):
876				v = bool(v)
877			elif isinstance(ov, int):
878				v = int(v)
879			elif isinstance(ov, list):
880				vv = v.split(',')
881				if vv == ['']:
882					vv = []
883				vv = [int(x, 0) if len(x) and x[0] in "0123456789" else x for x in vv]
884				if op == '=':
885					v = vv
886				elif op == '+=':
887					v = ov
888					v.extend(vv)
889				elif op == '-=':
890					v = ov
891					for x in vv:
892						if x in v:
893							v.remove(x)
894				else:
895					assert 0
896
897			opts[k] = v
898		self.set(**opts)
899
900		return ret
901
902class _AttendanceRecordingIdentityDict(object):
903	"""A dictionary-like object that records indices of items actually accessed
904	from a list."""
905
906	def __init__(self, lst):
907		self.l = lst
908		self.d = {id(v):i for i,v in enumerate(lst)}
909		self.s = set()
910
911	def __getitem__(self, v):
912		self.s.add(self.d[id(v)])
913		return v
914
915class _GregariousIdentityDict(object):
916	"""A dictionary-like object that welcomes guests without reservations and
917	adds them to the end of the guest list."""
918
919	def __init__(self, lst):
920		self.l = lst
921		self.s = set(id(v) for v in lst)
922
923	def __getitem__(self, v):
924		if id(v) not in self.s:
925			self.s.add(id(v))
926			self.l.append(v)
927		return v
928
929class _NonhashableDict(object):
930	"""A dictionary-like object mapping objects to values."""
931
932	def __init__(self, keys, values=None):
933		if values is None:
934			self.d = {id(v):i for i,v in enumerate(keys)}
935		else:
936			self.d = {id(k):v for k,v in zip(keys, values)}
937
938	def __getitem__(self, k):
939		return self.d[id(k)]
940
941	def __setitem__(self, k, v):
942		self.d[id(k)] = v
943
944	def __delitem__(self, k):
945		del self.d[id(k)]
946
947class Merger(object):
948
949	def __init__(self, options=None):
950
951		if not options:
952			options = Options()
953
954		self.options = options
955
956	def merge(self, fontfiles):
957
958		mega = ttLib.TTFont()
959
960		#
961		# Settle on a mega glyph order.
962		#
963		fonts = [ttLib.TTFont(fontfile) for fontfile in fontfiles]
964		glyphOrders = [font.getGlyphOrder() for font in fonts]
965		megaGlyphOrder = self._mergeGlyphOrders(glyphOrders)
966		# Reload fonts and set new glyph names on them.
967		# TODO Is it necessary to reload font?  I think it is.  At least
968		# it's safer, in case tables were loaded to provide glyph names.
969		fonts = [ttLib.TTFont(fontfile) for fontfile in fontfiles]
970		for font,glyphOrder in zip(fonts, glyphOrders):
971			font.setGlyphOrder(glyphOrder)
972		mega.setGlyphOrder(megaGlyphOrder)
973
974		for font in fonts:
975			self._preMerge(font)
976
977		self.fonts = fonts
978		self.duplicateGlyphsPerFont = [{} for f in fonts]
979
980		allTags = reduce(set.union, (list(font.keys()) for font in fonts), set())
981		allTags.remove('GlyphOrder')
982
983		# Make sure we process cmap before GSUB as we have a dependency there.
984		if 'GSUB' in allTags:
985			allTags.remove('GSUB')
986			allTags = ['GSUB'] + list(allTags)
987		if 'cmap' in allTags:
988			allTags.remove('cmap')
989			allTags = ['cmap'] + list(allTags)
990
991		for tag in allTags:
992			with timer("merge '%s'" % tag):
993				tables = [font.get(tag, NotImplemented) for font in fonts]
994
995				log.info("Merging '%s'.", tag)
996				clazz = ttLib.getTableClass(tag)
997				table = clazz(tag).merge(self, tables)
998				# XXX Clean this up and use:  table = mergeObjects(tables)
999
1000				if table is not NotImplemented and table is not False:
1001					mega[tag] = table
1002					log.info("Merged '%s'.", tag)
1003				else:
1004					log.info("Dropped '%s'.", tag)
1005
1006		del self.duplicateGlyphsPerFont
1007		del self.fonts
1008
1009		self._postMerge(mega)
1010
1011		return mega
1012
1013	def _mergeGlyphOrders(self, glyphOrders):
1014		"""Modifies passed-in glyphOrders to reflect new glyph names.
1015		Returns glyphOrder for the merged font."""
1016		# Simply append font index to the glyph name for now.
1017		# TODO Even this simplistic numbering can result in conflicts.
1018		# But then again, we have to improve this soon anyway.
1019		mega = []
1020		for n,glyphOrder in enumerate(glyphOrders):
1021			for i,glyphName in enumerate(glyphOrder):
1022				glyphName += "#" + repr(n)
1023				glyphOrder[i] = glyphName
1024				mega.append(glyphName)
1025		return mega
1026
1027	def mergeObjects(self, returnTable, logic, tables):
1028		# Right now we don't use self at all.  Will use in the future
1029		# for options and logging.
1030
1031		allKeys = set.union(set(), *(vars(table).keys() for table in tables if table is not NotImplemented))
1032		for key in allKeys:
1033			try:
1034				mergeLogic = logic[key]
1035			except KeyError:
1036				try:
1037					mergeLogic = logic['*']
1038				except KeyError:
1039					raise Exception("Don't know how to merge key %s of class %s" %
1040							(key, returnTable.__class__.__name__))
1041			if mergeLogic is NotImplemented:
1042				continue
1043			value = mergeLogic(getattr(table, key, NotImplemented) for table in tables)
1044			if value is not NotImplemented:
1045				setattr(returnTable, key, value)
1046
1047		return returnTable
1048
1049	def _preMerge(self, font):
1050
1051		# Map indices to references
1052
1053		GDEF = font.get('GDEF')
1054		GSUB = font.get('GSUB')
1055		GPOS = font.get('GPOS')
1056
1057		for t in [GSUB, GPOS]:
1058			if not t: continue
1059
1060			if t.table.LookupList:
1061				lookupMap = {i:v for i,v in enumerate(t.table.LookupList.Lookup)}
1062				t.table.LookupList.mapLookups(lookupMap)
1063				t.table.FeatureList.mapLookups(lookupMap)
1064
1065			if t.table.FeatureList and t.table.ScriptList:
1066				featureMap = {i:v for i,v in enumerate(t.table.FeatureList.FeatureRecord)}
1067				t.table.ScriptList.mapFeatures(featureMap)
1068
1069		# TODO GDEF/Lookup MarkFilteringSets
1070		# TODO FeatureParams nameIDs
1071
1072	def _postMerge(self, font):
1073
1074		# Map references back to indices
1075
1076		GDEF = font.get('GDEF')
1077		GSUB = font.get('GSUB')
1078		GPOS = font.get('GPOS')
1079
1080		for t in [GSUB, GPOS]:
1081			if not t: continue
1082
1083			if t.table.FeatureList and t.table.ScriptList:
1084
1085				# Collect unregistered (new) features.
1086				featureMap = _GregariousIdentityDict(t.table.FeatureList.FeatureRecord)
1087				t.table.ScriptList.mapFeatures(featureMap)
1088
1089				# Record used features.
1090				featureMap = _AttendanceRecordingIdentityDict(t.table.FeatureList.FeatureRecord)
1091				t.table.ScriptList.mapFeatures(featureMap)
1092				usedIndices = featureMap.s
1093
1094				# Remove unused features
1095				t.table.FeatureList.FeatureRecord = [f for i,f in enumerate(t.table.FeatureList.FeatureRecord) if i in usedIndices]
1096
1097				# Map back to indices.
1098				featureMap = _NonhashableDict(t.table.FeatureList.FeatureRecord)
1099				t.table.ScriptList.mapFeatures(featureMap)
1100
1101				t.table.FeatureList.FeatureCount = len(t.table.FeatureList.FeatureRecord)
1102
1103			if t.table.LookupList:
1104
1105				# Collect unregistered (new) lookups.
1106				lookupMap = _GregariousIdentityDict(t.table.LookupList.Lookup)
1107				t.table.FeatureList.mapLookups(lookupMap)
1108				t.table.LookupList.mapLookups(lookupMap)
1109
1110				# Record used lookups.
1111				lookupMap = _AttendanceRecordingIdentityDict(t.table.LookupList.Lookup)
1112				t.table.FeatureList.mapLookups(lookupMap)
1113				t.table.LookupList.mapLookups(lookupMap)
1114				usedIndices = lookupMap.s
1115
1116				# Remove unused lookups
1117				t.table.LookupList.Lookup = [l for i,l in enumerate(t.table.LookupList.Lookup) if i in usedIndices]
1118
1119				# Map back to indices.
1120				lookupMap = _NonhashableDict(t.table.LookupList.Lookup)
1121				t.table.FeatureList.mapLookups(lookupMap)
1122				t.table.LookupList.mapLookups(lookupMap)
1123
1124				t.table.LookupList.LookupCount = len(t.table.LookupList.Lookup)
1125
1126		# TODO GDEF/Lookup MarkFilteringSets
1127		# TODO FeatureParams nameIDs
1128
1129
1130__all__ = [
1131	'Options',
1132	'Merger',
1133	'main'
1134]
1135
1136@timer("make one with everything (TOTAL TIME)")
1137def main(args=None):
1138	from fontTools import configLogger
1139
1140	if args is None:
1141		args = sys.argv[1:]
1142
1143	options = Options()
1144	args = options.parse_opts(args)
1145
1146	if len(args) < 1:
1147		print("usage: pyftmerge font...", file=sys.stderr)
1148		return 1
1149
1150	configLogger(level=logging.INFO if options.verbose else logging.WARNING)
1151	if options.timing:
1152		timer.logger.setLevel(logging.DEBUG)
1153	else:
1154		timer.logger.disabled = True
1155
1156	merger = Merger(options=options)
1157	font = merger.merge(args)
1158	outfile = 'merged.ttf'
1159	with timer("compile and save font"):
1160		font.save(outfile)
1161
1162
1163if __name__ == "__main__":
1164	sys.exit(main())
1165