blob: b218cbfe99001e9812fa19d06936ef95d274b2c2 [file] [log] [blame]
Behdad Esfahbod45d2f382013-09-18 20:47:53 -04001# Copyright 2013 Google, Inc. All Rights Reserved.
2#
3# Google Author(s): Behdad Esfahbod
4
5"""Font merger.
6"""
7
8import sys
Behdad Esfahbodf2d59822013-09-19 16:16:39 -04009import time
Behdad Esfahbod45d2f382013-09-18 20:47:53 -040010
11import fontTools
12from fontTools import misc, ttLib, cffLib
13
14def _add_method(*clazzes):
Behdad Esfahbodc855f3a2013-09-19 20:09:23 -040015 """Returns a decorator function that adds a new method to one or
16 more classes."""
17 def wrapper(method):
18 for clazz in clazzes:
19 assert clazz.__name__ != 'DefaultTable', 'Oops, table class not found.'
20 assert not hasattr(clazz, method.func_name), \
21 "Oops, class '%s' has method '%s'." % (clazz.__name__,
22 method.func_name)
23 setattr(clazz, method.func_name, method)
24 return None
25 return wrapper
Behdad Esfahbod45d2f382013-09-18 20:47:53 -040026
27
28@_add_method(fontTools.ttLib.getTableClass('maxp'))
29def merge(self, tables, fonts):
30 # TODO When we correctly merge hinting data, update these values:
31 # maxFunctionDefs, maxInstructionDefs, maxSizeOfInstructions
32 # TODO Assumes that all tables have format 1.0; safe assumption.
33 for key in set(sum((vars(table).keys() for table in tables), [])):
34 setattr(self, key, max(getattr(table, key) for table in tables))
Behdad Esfahbod65f19d82013-09-18 21:02:41 -040035 return True
36
Behdad Esfahbodf2d59822013-09-19 16:16:39 -040037@_add_method(fontTools.ttLib.getTableClass('head'))
38def merge(self, tables, fonts):
39 # TODO Check that unitsPerEm are the same.
40 # TODO Use bitwise ops for flags, macStyle, fontDirectionHint
41 minMembers = ['xMin', 'yMin']
42 # Negate some members
43 for key in minMembers:
44 for table in tables:
45 setattr(table, key, -getattr(table, key))
46 # Get max over members
47 for key in set(sum((vars(table).keys() for table in tables), [])):
48 setattr(self, key, max(getattr(table, key) for table in tables))
49 # Negate them back
50 for key in minMembers:
51 for table in tables:
52 setattr(table, key, -getattr(table, key))
53 setattr(self, key, -getattr(self, key))
54 return True
55
Behdad Esfahbod65f19d82013-09-18 21:02:41 -040056@_add_method(fontTools.ttLib.getTableClass('hhea'))
57def merge(self, tables, fonts):
58 # TODO Check that ascent, descent, slope, etc are the same.
59 minMembers = ['descent', 'minLeftSideBearing', 'minRightSideBearing']
60 # Negate some members
61 for key in minMembers:
62 for table in tables:
63 setattr(table, key, -getattr(table, key))
64 # Get max over members
65 for key in set(sum((vars(table).keys() for table in tables), [])):
66 setattr(self, key, max(getattr(table, key) for table in tables))
67 # Negate them back
68 for key in minMembers:
69 for table in tables:
70 setattr(table, key, -getattr(table, key))
71 setattr(self, key, -getattr(self, key))
72 return True
73
Behdad Esfahbod71294de2013-09-19 19:43:17 -040074@_add_method(fontTools.ttLib.getTableClass('OS/2'))
75def merge(self, tables, fonts):
76 # TODO Check that weight/width/subscript/superscript/etc are the same.
77 # TODO Bitwise ops for UnicodeRange/CodePageRange.
78 # TODO Pretty much all fields generated here have bogus values.
79 # Get max over members
80 for key in set(sum((vars(table).keys() for table in tables), [])):
81 setattr(self, key, max(getattr(table, key) for table in tables))
82 return True
83
Behdad Esfahbodf2d59822013-09-19 16:16:39 -040084@_add_method(fontTools.ttLib.getTableClass('post'))
85def merge(self, tables, fonts):
86 # TODO Check that italicAngle, underlinePosition, underlineThickness are the same.
87 minMembers = ['underlinePosition', 'minMemType42', 'minMemType1']
88 # Negate some members
89 for key in minMembers:
90 for table in tables:
91 setattr(table, key, -getattr(table, key))
92 # Get max over members
93 keys = set(sum((vars(table).keys() for table in tables), []))
94 if 'mapping' in keys:
95 keys.remove('mapping')
96 keys.remove('extraNames')
97 for key in keys:
98 setattr(self, key, max(getattr(table, key) for table in tables))
99 # Negate them back
100 for key in minMembers:
101 for table in tables:
102 setattr(table, key, -getattr(table, key))
103 setattr(self, key, -getattr(self, key))
104 self.mapping = {}
105 for table in tables:
106 if hasattr(table, 'mapping'):
107 self.mapping.update(table.mapping)
108 self.extraNames = []
109 return True
110
Behdad Esfahbod65f19d82013-09-18 21:02:41 -0400111@_add_method(fontTools.ttLib.getTableClass('vmtx'),
112 fontTools.ttLib.getTableClass('hmtx'))
113def merge(self, tables, fonts):
114 self.metrics = {}
115 for table in tables:
116 self.metrics.update(table.metrics)
117 return True
118
119@_add_method(fontTools.ttLib.getTableClass('loca'))
120def merge(self, tables, fonts):
Behdad Esfahbodf2d59822013-09-19 16:16:39 -0400121 return True # Will be computed automatically
122
123@_add_method(fontTools.ttLib.getTableClass('glyf'))
124def merge(self, tables, fonts):
125 self.glyphs = {}
126 for table in tables:
127 self.glyphs.update(table.glyphs)
128 # TODO Drop hints?
129 return True
130
131@_add_method(fontTools.ttLib.getTableClass('prep'),
132 fontTools.ttLib.getTableClass('fpgm'),
133 fontTools.ttLib.getTableClass('cvt '))
134def merge(self, tables, fonts):
Behdad Esfahbod65f19d82013-09-18 21:02:41 -0400135 return False # Will be computed automatically
Behdad Esfahbod45d2f382013-09-18 20:47:53 -0400136
Behdad Esfahbod71294de2013-09-19 19:43:17 -0400137@_add_method(fontTools.ttLib.getTableClass('cmap'))
138def merge(self, tables, fonts):
139 # TODO Handle format=14.
140 cmapTables = [t for table in tables for t in table.tables
141 if t.platformID == 3 and t.platEncID in [1, 10]]
142 # TODO Better handle format-4 and format-12 coexisting in same font.
143 # TODO Insert both a format-4 and format-12 if needed.
144 module = fontTools.ttLib.getTableModule('cmap')
145 assert all(t.format in [4, 12] for t in cmapTables)
146 format = max(t.format for t in cmapTables)
147 cmapTable = module.cmap_classes[format](format)
148 cmapTable.cmap = {}
149 cmapTable.platformID = 3
150 cmapTable.platEncID = max(t.platEncID for t in cmapTables)
151 cmapTable.language = 0
152 for table in cmapTables:
153 # TODO handle duplicates.
154 cmapTable.cmap.update(table.cmap)
155 self.tableVersion = 0
156 self.tables = [cmapTable]
157 self.numSubTables = len(self.tables)
158 return True
159
Behdad Esfahbodf2d59822013-09-19 16:16:39 -0400160
161class Options(object):
162
163 class UnknownOptionError(Exception):
164 pass
165
166 _drop_tables_default = ['fpgm', 'prep', 'cvt ', 'gasp']
167 drop_tables = _drop_tables_default
168
169 def __init__(self, **kwargs):
170
171 self.set(**kwargs)
172
173 def set(self, **kwargs):
174 for k,v in kwargs.iteritems():
175 if not hasattr(self, k):
176 raise self.UnknownOptionError("Unknown option '%s'" % k)
177 setattr(self, k, v)
178
179 def parse_opts(self, argv, ignore_unknown=False):
180 ret = []
181 opts = {}
182 for a in argv:
183 orig_a = a
184 if not a.startswith('--'):
185 ret.append(a)
186 continue
187 a = a[2:]
188 i = a.find('=')
189 op = '='
190 if i == -1:
191 if a.startswith("no-"):
192 k = a[3:]
193 v = False
194 else:
195 k = a
196 v = True
197 else:
198 k = a[:i]
199 if k[-1] in "-+":
200 op = k[-1]+'=' # Ops is '-=' or '+=' now.
201 k = k[:-1]
202 v = a[i+1:]
203 k = k.replace('-', '_')
204 if not hasattr(self, k):
205 if ignore_unknown == True or k in ignore_unknown:
206 ret.append(orig_a)
207 continue
208 else:
209 raise self.UnknownOptionError("Unknown option '%s'" % a)
210
211 ov = getattr(self, k)
212 if isinstance(ov, bool):
213 v = bool(v)
214 elif isinstance(ov, int):
215 v = int(v)
216 elif isinstance(ov, list):
217 vv = v.split(',')
218 if vv == ['']:
219 vv = []
220 vv = [int(x, 0) if len(x) and x[0] in "0123456789" else x for x in vv]
221 if op == '=':
222 v = vv
223 elif op == '+=':
224 v = ov
225 v.extend(vv)
226 elif op == '-=':
227 v = ov
228 for x in vv:
229 if x in v:
230 v.remove(x)
231 else:
232 assert 0
233
234 opts[k] = v
235 self.set(**opts)
236
237 return ret
238
239
Behdad Esfahbod45d2f382013-09-18 20:47:53 -0400240class Merger:
241
Behdad Esfahbodf2d59822013-09-19 16:16:39 -0400242 def __init__(self, options=None, log=None):
Behdad Esfahbod45d2f382013-09-18 20:47:53 -0400243
Behdad Esfahbodf2d59822013-09-19 16:16:39 -0400244 if not log:
245 log = Logger()
246 if not options:
247 options = Options()
248
249 self.options = options
250 self.log = log
251
252 def merge(self, fontfiles):
Behdad Esfahbod45d2f382013-09-18 20:47:53 -0400253
254 mega = ttLib.TTFont()
255
256 #
257 # Settle on a mega glyph order.
258 #
Behdad Esfahbodf2d59822013-09-19 16:16:39 -0400259 fonts = [ttLib.TTFont(fontfile) for fontfile in fontfiles]
Behdad Esfahbod45d2f382013-09-18 20:47:53 -0400260 glyphOrders = [font.getGlyphOrder() for font in fonts]
261 megaGlyphOrder = self._mergeGlyphOrders(glyphOrders)
262 # Reload fonts and set new glyph names on them.
263 # TODO Is it necessary to reload font? I think it is. At least
264 # it's safer, in case tables were loaded to provide glyph names.
Behdad Esfahbodf2d59822013-09-19 16:16:39 -0400265 fonts = [ttLib.TTFont(fontfile) for fontfile in fontfiles]
Behdad Esfahbod45d2f382013-09-18 20:47:53 -0400266 map(ttLib.TTFont.setGlyphOrder, fonts, glyphOrders)
267 mega.setGlyphOrder(megaGlyphOrder)
268
269 cmaps = [self._get_cmap(font) for font in fonts]
270
271 allTags = set(sum([font.keys() for font in fonts], []))
272 allTags.remove('GlyphOrder')
273 for tag in allTags:
Behdad Esfahbodf2d59822013-09-19 16:16:39 -0400274
275 if tag in self.options.drop_tables:
Behdad Esfahbodb640f742013-09-19 20:12:56 -0400276 self.log("Dropping '%s'." % tag)
Behdad Esfahbodf2d59822013-09-19 16:16:39 -0400277 continue
278
Behdad Esfahbod45d2f382013-09-18 20:47:53 -0400279 clazz = ttLib.getTableClass(tag)
280
281 if not hasattr(clazz, 'merge'):
Behdad Esfahbodb640f742013-09-19 20:12:56 -0400282 self.log("Don't know how to merge '%s', dropped." % tag)
Behdad Esfahbod45d2f382013-09-18 20:47:53 -0400283 continue
284
285 # TODO For now assume all fonts have the same tables.
286 tables = [font[tag] for font in fonts]
287 table = clazz(tag)
Behdad Esfahbod65f19d82013-09-18 21:02:41 -0400288 if table.merge (tables, fonts):
289 mega[tag] = table
Behdad Esfahbodb640f742013-09-19 20:12:56 -0400290 self.log("Merged '%s'." % tag)
Behdad Esfahbod65f19d82013-09-18 21:02:41 -0400291 else:
Behdad Esfahbodb640f742013-09-19 20:12:56 -0400292 self.log("Dropped '%s'. No need to merge explicitly." % tag)
293 self.log.lapse("merge '%s'" % tag)
Behdad Esfahbod45d2f382013-09-18 20:47:53 -0400294
295 return mega
296
297 def _get_cmap(self, font):
298 cmap = font['cmap']
299 tables = [t for t in cmap.tables
300 if t.platformID == 3 and t.platEncID in [1, 10]]
301 # XXX Handle format=14
302 assert len(tables)
303 # Pick table that has largest coverage
304 table = max(tables, key=lambda t: len(t.cmap))
305 return table
306
307 def _mergeGlyphOrders(self, glyphOrders):
308 """Modifies passed-in glyphOrders to reflect new glyph names."""
309 # Simply append font index to the glyph name for now.
310 mega = []
311 for n,glyphOrder in enumerate(glyphOrders):
312 for i,glyphName in enumerate(glyphOrder):
313 glyphName += "#" + `n`
314 glyphOrder[i] = glyphName
315 mega.append(glyphName)
316 return mega
317
Behdad Esfahbodf2d59822013-09-19 16:16:39 -0400318
319class Logger(object):
320
321 def __init__(self, verbose=False, xml=False, timing=False):
322 self.verbose = verbose
323 self.xml = xml
324 self.timing = timing
325 self.last_time = self.start_time = time.time()
326
327 def parse_opts(self, argv):
328 argv = argv[:]
329 for v in ['verbose', 'xml', 'timing']:
330 if "--"+v in argv:
331 setattr(self, v, True)
332 argv.remove("--"+v)
333 return argv
334
335 def __call__(self, *things):
336 if not self.verbose:
337 return
338 print ' '.join(str(x) for x in things)
339
340 def lapse(self, *things):
341 if not self.timing:
342 return
343 new_time = time.time()
344 print "Took %0.3fs to %s" %(new_time - self.last_time,
345 ' '.join(str(x) for x in things))
346 self.last_time = new_time
347
348 def font(self, font, file=sys.stdout):
349 if not self.xml:
350 return
351 from fontTools.misc import xmlWriter
352 writer = xmlWriter.XMLWriter(file)
353 font.disassembleInstructions = False # Work around ttLib bug
354 for tag in font.keys():
355 writer.begintag(tag)
356 writer.newline()
357 font[tag].toXML(writer, font)
358 writer.endtag(tag)
359 writer.newline()
360
361
362__all__ = [
363 'Options',
364 'Merger',
365 'Logger',
366 'main'
367]
368
Behdad Esfahbod45d2f382013-09-18 20:47:53 -0400369def main(args):
Behdad Esfahbodf2d59822013-09-19 16:16:39 -0400370
371 log = Logger()
372 args = log.parse_opts(args)
373
374 options = Options()
375 args = options.parse_opts(args)
376
Behdad Esfahbod45d2f382013-09-18 20:47:53 -0400377 if len(args) < 1:
378 print >>sys.stderr, "usage: pyftmerge font..."
379 sys.exit(1)
Behdad Esfahbodf2d59822013-09-19 16:16:39 -0400380
381 merger = Merger(options=options, log=log)
382 font = merger.merge(args)
Behdad Esfahbod45d2f382013-09-18 20:47:53 -0400383 outfile = 'merged.ttf'
384 font.save(outfile)
Behdad Esfahbodb640f742013-09-19 20:12:56 -0400385 log.lapse("compile and save font")
386
387 log.last_time = log.start_time
388 log.lapse("make one with everything(TOTAL TIME)")
Behdad Esfahbod45d2f382013-09-18 20:47:53 -0400389
390if __name__ == "__main__":
391 main(sys.argv[1:])