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