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