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