merge.py revision 436503372a7a4aeb529a360e66114ce1b1b6d9ce
1# Copyright 2013 Google, Inc. All Rights Reserved. 2# 3# Google Author(s): Behdad Esfahbod 4 5"""Font merger. 6""" 7 8import sys 9import time 10 11import fontTools 12from fontTools import misc, ttLib, cffLib 13from fontTools.ttLib.tables import otTables 14 15def _add_method(*clazzes): 16 """Returns a decorator function that adds a new method to one or 17 more classes.""" 18 def wrapper(method): 19 for clazz in clazzes: 20 assert clazz.__name__ != 'DefaultTable', 'Oops, table class not found.' 21 assert not hasattr(clazz, method.func_name), \ 22 "Oops, class '%s' has method '%s'." % (clazz.__name__, 23 method.func_name) 24 setattr(clazz, method.func_name, method) 25 return None 26 return wrapper 27 28 29@_add_method(ttLib.getTableClass('maxp')) 30def merge(self, m): 31 # TODO When we correctly merge hinting data, update these values: 32 # maxFunctionDefs, maxInstructionDefs, maxSizeOfInstructions 33 # TODO Assumes that all tables have format 1.0; safe assumption. 34 for key in set(sum((vars(table).keys() for table in m.tables), [])): 35 setattr(self, key, max(getattr(table, key) for table in m.tables)) 36 return True 37 38@_add_method(ttLib.getTableClass('head')) 39def merge(self, m): 40 # TODO Check that unitsPerEm are the same. 41 # TODO Use bitwise ops for flags, macStyle, fontDirectionHint 42 minMembers = ['xMin', 'yMin'] 43 # Negate some members 44 for key in minMembers: 45 for table in m.tables: 46 setattr(table, key, -getattr(table, key)) 47 # Get max over members 48 for key in set(sum((vars(table).keys() for table in m.tables), [])): 49 setattr(self, key, max(getattr(table, key) for table in m.tables)) 50 # Negate them back 51 for key in minMembers: 52 for table in m.tables: 53 setattr(table, key, -getattr(table, key)) 54 setattr(self, key, -getattr(self, key)) 55 return True 56 57@_add_method(ttLib.getTableClass('hhea')) 58def merge(self, m): 59 # TODO Check that ascent, descent, slope, etc are the same. 60 minMembers = ['descent', 'minLeftSideBearing', 'minRightSideBearing'] 61 # Negate some members 62 for key in minMembers: 63 for table in m.tables: 64 setattr(table, key, -getattr(table, key)) 65 # Get max over members 66 for key in set(sum((vars(table).keys() for table in m.tables), [])): 67 setattr(self, key, max(getattr(table, key) for table in m.tables)) 68 # Negate them back 69 for key in minMembers: 70 for table in m.tables: 71 setattr(table, key, -getattr(table, key)) 72 setattr(self, key, -getattr(self, key)) 73 return True 74 75@_add_method(ttLib.getTableClass('OS/2')) 76def merge(self, m): 77 # TODO Check that weight/width/subscript/superscript/etc are the same. 78 # TODO Bitwise ops for UnicodeRange/CodePageRange. 79 # TODO Pretty much all fields generated here have bogus values. 80 # Get max over members 81 for key in set(sum((vars(table).keys() for table in m.tables), [])): 82 setattr(self, key, max(getattr(table, key) for table in m.tables)) 83 return True 84 85@_add_method(ttLib.getTableClass('post')) 86def merge(self, m): 87 # TODO Check that italicAngle, underlinePosition, underlineThickness are the same. 88 minMembers = ['underlinePosition', 'minMemType42', 'minMemType1'] 89 # Negate some members 90 for key in minMembers: 91 for table in m.tables: 92 setattr(table, key, -getattr(table, key)) 93 # Get max over members 94 keys = set(sum((vars(table).keys() for table in m.tables), [])) 95 if 'mapping' in keys: 96 keys.remove('mapping') 97 keys.remove('extraNames') 98 for key in keys: 99 setattr(self, key, max(getattr(table, key) for table in m.tables)) 100 # Negate them back 101 for key in minMembers: 102 for table in m.tables: 103 setattr(table, key, -getattr(table, key)) 104 setattr(self, key, -getattr(self, key)) 105 self.mapping = {} 106 for table in m.tables: 107 if hasattr(table, 'mapping'): 108 self.mapping.update(table.mapping) 109 self.extraNames = [] 110 return True 111 112@_add_method(ttLib.getTableClass('vmtx'), 113 ttLib.getTableClass('hmtx')) 114def merge(self, m): 115 self.metrics = {} 116 for table in m.tables: 117 self.metrics.update(table.metrics) 118 return True 119 120@_add_method(ttLib.getTableClass('loca')) 121def merge(self, m): 122 return True # Will be computed automatically 123 124@_add_method(ttLib.getTableClass('glyf')) 125def merge(self, m): 126 self.glyphs = {} 127 for table in m.tables: 128 for g in table.glyphs.values(): 129 # Drop hints for now, since we don't remap 130 # functions / CVT values. 131 g.removeHinting() 132 # Expand composite glyphs to load their 133 # composite glyph names. 134 if g.isComposite(): 135 g.expand(table) 136 self.glyphs.update(table.glyphs) 137 return True 138 139@_add_method(ttLib.getTableClass('prep'), 140 ttLib.getTableClass('fpgm'), 141 ttLib.getTableClass('cvt ')) 142def merge(self, m): 143 return False # Will be computed automatically 144 145@_add_method(ttLib.getTableClass('cmap')) 146def merge(self, m): 147 # TODO Handle format=14. 148 cmapTables = [t for table in m.tables for t in table.tables 149 if t.platformID == 3 and t.platEncID in [1, 10]] 150 # TODO Better handle format-4 and format-12 coexisting in same font. 151 # TODO Insert both a format-4 and format-12 if needed. 152 module = ttLib.getTableModule('cmap') 153 assert all(t.format in [4, 12] for t in cmapTables) 154 format = max(t.format for t in cmapTables) 155 cmapTable = module.cmap_classes[format](format) 156 cmapTable.cmap = {} 157 cmapTable.platformID = 3 158 cmapTable.platEncID = max(t.platEncID for t in cmapTables) 159 cmapTable.language = 0 160 for table in cmapTables: 161 # TODO handle duplicates. 162 cmapTable.cmap.update(table.cmap) 163 self.tableVersion = 0 164 self.tables = [cmapTable] 165 self.numSubTables = len(self.tables) 166 return True 167 168@_add_method(ttLib.getTableClass('GDEF')) 169def merge(self, m): 170 self.table = otTables.GDEF() 171 self.table.Version = 1.0 172 173 if any(t.table.LigCaretList for t in m.tables): 174 glyphs = [] 175 ligGlyphs = [] 176 for table in m.tables: 177 if table.table.LigCaretList: 178 glyphs.extend(table.table.LigCaretList.Coverage.glyphs) 179 ligGlyphs.extend(table.table.LigCaretList.LigGlyph) 180 coverage = otTables.Coverage() 181 coverage.glyphs = glyphs 182 ligCaretList = otTables.LigCaretList() 183 ligCaretList.Coverage = coverage 184 ligCaretList.LigGlyph = ligGlyphs 185 ligCaretList.GlyphCount = len(ligGlyphs) 186 self.table.LigCaretList = ligCaretList 187 else: 188 self.table.LigCaretList = None 189 190 if any(t.table.MarkAttachClassDef for t in m.tables): 191 classDefs = {} 192 for table in m.tables: 193 if table.table.MarkAttachClassDef: 194 classDefs.update(table.table.MarkAttachClassDef.classDefs) 195 self.table.MarkAttachClassDef = otTables.MarkAttachClassDef() 196 self.table.MarkAttachClassDef.classDefs = classDefs 197 else: 198 self.table.MarkAttachClassDef = None 199 200 if any(t.table.GlyphClassDef for t in m.tables): 201 classDefs = {} 202 for table in m.tables: 203 if table.table.GlyphClassDef: 204 classDefs.update(table.table.GlyphClassDef.classDefs) 205 self.table.GlyphClassDef = otTables.GlyphClassDef() 206 self.table.GlyphClassDef.classDefs = classDefs 207 else: 208 self.table.GlyphClassDef = None 209 210 if any(t.table.AttachList for t in m.tables): 211 glyphs = [] 212 attachPoints = [] 213 for table in m.tables: 214 if table.table.AttachList: 215 glyphs.extend(table.table.AttachList.Coverage.glyphs) 216 attachPoints.extend(table.table.AttachList.AttachPoint) 217 coverage = otTables.Coverage() 218 coverage.glyphs = glyphs 219 attachList = otTables.AttachList() 220 attachList.Coverage = coverage 221 attachList.AttachPoint = attachPoints 222 attachList.GlyphCount = len(attachPoints) 223 self.table.AttachList = attachList 224 else: 225 self.table.AttachList = None 226 227 return True 228 229 230class Options(object): 231 232 class UnknownOptionError(Exception): 233 pass 234 235 _drop_tables_default = ['fpgm', 'prep', 'cvt ', 'gasp'] 236 drop_tables = _drop_tables_default 237 238 def __init__(self, **kwargs): 239 240 self.set(**kwargs) 241 242 def set(self, **kwargs): 243 for k,v in kwargs.iteritems(): 244 if not hasattr(self, k): 245 raise self.UnknownOptionError("Unknown option '%s'" % k) 246 setattr(self, k, v) 247 248 def parse_opts(self, argv, ignore_unknown=False): 249 ret = [] 250 opts = {} 251 for a in argv: 252 orig_a = a 253 if not a.startswith('--'): 254 ret.append(a) 255 continue 256 a = a[2:] 257 i = a.find('=') 258 op = '=' 259 if i == -1: 260 if a.startswith("no-"): 261 k = a[3:] 262 v = False 263 else: 264 k = a 265 v = True 266 else: 267 k = a[:i] 268 if k[-1] in "-+": 269 op = k[-1]+'=' # Ops is '-=' or '+=' now. 270 k = k[:-1] 271 v = a[i+1:] 272 k = k.replace('-', '_') 273 if not hasattr(self, k): 274 if ignore_unknown == True or k in ignore_unknown: 275 ret.append(orig_a) 276 continue 277 else: 278 raise self.UnknownOptionError("Unknown option '%s'" % a) 279 280 ov = getattr(self, k) 281 if isinstance(ov, bool): 282 v = bool(v) 283 elif isinstance(ov, int): 284 v = int(v) 285 elif isinstance(ov, list): 286 vv = v.split(',') 287 if vv == ['']: 288 vv = [] 289 vv = [int(x, 0) if len(x) and x[0] in "0123456789" else x for x in vv] 290 if op == '=': 291 v = vv 292 elif op == '+=': 293 v = ov 294 v.extend(vv) 295 elif op == '-=': 296 v = ov 297 for x in vv: 298 if x in v: 299 v.remove(x) 300 else: 301 assert 0 302 303 opts[k] = v 304 self.set(**opts) 305 306 return ret 307 308 309class Merger: 310 311 def __init__(self, options=None, log=None): 312 313 if not log: 314 log = Logger() 315 if not options: 316 options = Options() 317 318 self.options = options 319 self.log = log 320 321 def merge(self, fontfiles): 322 323 mega = ttLib.TTFont() 324 325 # 326 # Settle on a mega glyph order. 327 # 328 fonts = [ttLib.TTFont(fontfile) for fontfile in fontfiles] 329 glyphOrders = [font.getGlyphOrder() for font in fonts] 330 megaGlyphOrder = self._mergeGlyphOrders(glyphOrders) 331 # Reload fonts and set new glyph names on them. 332 # TODO Is it necessary to reload font? I think it is. At least 333 # it's safer, in case tables were loaded to provide glyph names. 334 fonts = [ttLib.TTFont(fontfile) for fontfile in fontfiles] 335 for font,glyphOrder in zip(fonts, glyphOrders): 336 font.setGlyphOrder(glyphOrder) 337 mega.setGlyphOrder(megaGlyphOrder) 338 339 allTags = reduce(set.union, (font.keys() for font in fonts), set()) 340 allTags.remove('GlyphOrder') 341 for tag in allTags: 342 343 if tag in self.options.drop_tables: 344 self.log("Dropping '%s'." % tag) 345 continue 346 347 clazz = ttLib.getTableClass(tag) 348 349 if not hasattr(clazz, 'merge'): 350 self.log("Don't know how to merge '%s', dropped." % tag) 351 continue 352 353 # TODO For now assume all fonts have the same tables. 354 self.tables = [font[tag] for font in fonts] 355 table = clazz(tag) 356 if table.merge (self): 357 mega[tag] = table 358 self.log("Merged '%s'." % tag) 359 else: 360 self.log("Dropped '%s'. No need to merge explicitly." % tag) 361 self.log.lapse("merge '%s'" % tag) 362 del self.tables 363 364 return mega 365 366 def _mergeGlyphOrders(self, glyphOrders): 367 """Modifies passed-in glyphOrders to reflect new glyph names. 368 Returns glyphOrder for the merged font.""" 369 # Simply append font index to the glyph name for now. 370 # TODO Even this simplistic numbering can result in conflicts. 371 # But then again, we have to improve this soon anyway. 372 mega = [] 373 for n,glyphOrder in enumerate(glyphOrders): 374 for i,glyphName in enumerate(glyphOrder): 375 glyphName += "#" + `n` 376 glyphOrder[i] = glyphName 377 mega.append(glyphName) 378 return mega 379 380 381class Logger(object): 382 383 def __init__(self, verbose=False, xml=False, timing=False): 384 self.verbose = verbose 385 self.xml = xml 386 self.timing = timing 387 self.last_time = self.start_time = time.time() 388 389 def parse_opts(self, argv): 390 argv = argv[:] 391 for v in ['verbose', 'xml', 'timing']: 392 if "--"+v in argv: 393 setattr(self, v, True) 394 argv.remove("--"+v) 395 return argv 396 397 def __call__(self, *things): 398 if not self.verbose: 399 return 400 print ' '.join(str(x) for x in things) 401 402 def lapse(self, *things): 403 if not self.timing: 404 return 405 new_time = time.time() 406 print "Took %0.3fs to %s" %(new_time - self.last_time, 407 ' '.join(str(x) for x in things)) 408 self.last_time = new_time 409 410 def font(self, font, file=sys.stdout): 411 if not self.xml: 412 return 413 from fontTools.misc import xmlWriter 414 writer = xmlWriter.XMLWriter(file) 415 font.disassembleInstructions = False # Work around ttLib bug 416 for tag in font.keys(): 417 writer.begintag(tag) 418 writer.newline() 419 font[tag].toXML(writer, font) 420 writer.endtag(tag) 421 writer.newline() 422 423 424__all__ = [ 425 'Options', 426 'Merger', 427 'Logger', 428 'main' 429] 430 431def main(args): 432 433 log = Logger() 434 args = log.parse_opts(args) 435 436 options = Options() 437 args = options.parse_opts(args) 438 439 if len(args) < 1: 440 print >>sys.stderr, "usage: pyftmerge font..." 441 sys.exit(1) 442 443 merger = Merger(options=options, log=log) 444 font = merger.merge(args) 445 outfile = 'merged.ttf' 446 font.save(outfile) 447 log.lapse("compile and save font") 448 449 log.last_time = log.start_time 450 log.lapse("make one with everything(TOTAL TIME)") 451 452if __name__ == "__main__": 453 main(sys.argv[1:]) 454