merge.py revision 398770d51c3ae0e7c267ef2b315beae2b6aa2df8
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('loca').mergeMap = { 224 '*': recalculate, 225 'tableTag': equal, 226} 227 228ttLib.getTableClass('glyf').mergeMap = { 229 'tableTag': equal, 230 'glyphs': sumDicts, 231 'glyphOrder': sumLists, 232} 233 234@_add_method(ttLib.getTableClass('glyf')) 235def merge(self, m, tables): 236 for table in tables: 237 for g in table.glyphs.values(): 238 # Drop hints for now, since we don't remap 239 # functions / CVT values. 240 g.removeHinting() 241 # Expand composite glyphs to load their 242 # composite glyph names. 243 if g.isComposite(): 244 g.expand(table) 245 return DefaultTable.merge(self, m, tables) 246 247ttLib.getTableClass('prep').mergeMap = NotImplemented 248ttLib.getTableClass('fpgm').mergeMap = NotImplemented 249ttLib.getTableClass('cvt ').mergeMap = NotImplemented 250 251@_add_method(ttLib.getTableClass('cmap')) 252def merge(self, m, tables): 253 # TODO Handle format=14. 254 cmapTables = [t for table in tables for t in table.tables 255 if t.platformID == 3 and t.platEncID in [1, 10]] 256 # TODO Better handle format-4 and format-12 coexisting in same font. 257 # TODO Insert both a format-4 and format-12 if needed. 258 module = ttLib.getTableModule('cmap') 259 assert all(t.format in [4, 12] for t in cmapTables) 260 format = max(t.format for t in cmapTables) 261 cmapTable = module.cmap_classes[format](format) 262 cmapTable.cmap = {} 263 cmapTable.platformID = 3 264 cmapTable.platEncID = max(t.platEncID for t in cmapTables) 265 cmapTable.language = 0 266 for table in cmapTables: 267 # TODO handle duplicates. 268 cmapTable.cmap.update(table.cmap) 269 self.tableVersion = 0 270 self.tables = [cmapTable] 271 self.numSubTables = len(self.tables) 272 return self 273 274 275otTables.ScriptList.mergeMap = { 276 'ScriptCount': sum, 277 'ScriptRecord': sumLists, 278} 279 280otTables.FeatureList.mergeMap = { 281 'FeatureCount': sum, 282 'FeatureRecord': sumLists, 283} 284 285otTables.LookupList.mergeMap = { 286 'LookupCount': sum, 287 'Lookup': sumLists, 288} 289 290otTables.Coverage.mergeMap = { 291 'glyphs': sumLists, 292} 293 294otTables.ClassDef.mergeMap = { 295 'classDefs': sumDicts, 296} 297 298otTables.LigCaretList.mergeMap = { 299 'Coverage': mergeObjects, 300 'LigGlyphCount': sum, 301 'LigGlyph': sumLists, 302} 303 304otTables.AttachList.mergeMap = { 305 'Coverage': mergeObjects, 306 'GlyphCount': sum, 307 'AttachPoint': sumLists, 308} 309 310# XXX Renumber MarkFilterSets of lookups 311otTables.MarkGlyphSetsDef.mergeMap = { 312 'MarkSetTableFormat': equal, 313 'MarkSetCount': sum, 314 'Coverage': sumLists, 315} 316 317otTables.GDEF.mergeMap = { 318 '*': mergeObjects, 319 'Version': max, 320} 321 322otTables.GSUB.mergeMap = otTables.GPOS.mergeMap = { 323 '*': mergeObjects, 324 'Version': max, 325} 326 327ttLib.getTableClass('GDEF').mergeMap = \ 328ttLib.getTableClass('GSUB').mergeMap = \ 329ttLib.getTableClass('GPOS').mergeMap = \ 330ttLib.getTableClass('BASE').mergeMap = \ 331ttLib.getTableClass('JSTF').mergeMap = \ 332ttLib.getTableClass('MATH').mergeMap = \ 333{ 334 'tableTag': equal, 335 'table': mergeObjects, 336} 337 338 339@_add_method(otTables.Feature) 340def mapLookups(self, lookupMap): 341 self.LookupListIndex = [lookupMap[i] for i in self.LookupListIndex] 342 343@_add_method(otTables.FeatureList) 344def mapLookups(self, lookupMap): 345 for f in self.FeatureRecord: 346 if not f or not f.Feature: continue 347 f.Feature.mapLookups(lookupMap) 348 349@_add_method(otTables.DefaultLangSys, 350 otTables.LangSys) 351def mapFeatures(self, featureMap): 352 self.FeatureIndex = [featureMap[i] for i in self.FeatureIndex] 353 if self.ReqFeatureIndex != 65535: 354 self.ReqFeatureIndex = featureMap[self.ReqFeatureIndex] 355 356@_add_method(otTables.Script) 357def mapFeatures(self, featureMap): 358 if self.DefaultLangSys: 359 self.DefaultLangSys.mapFeatures(featureMap) 360 for l in self.LangSysRecord: 361 if not l or not l.LangSys: continue 362 l.LangSys.mapFeatures(featureMap) 363 364@_add_method(otTables.ScriptList) 365def mapFeatures(self, featureMap): 366 for s in self.ScriptRecord: 367 if not s or not s.Script: continue 368 s.Script.mapFeatures(featureMap) 369 370 371class Options(object): 372 373 class UnknownOptionError(Exception): 374 pass 375 376 def __init__(self, **kwargs): 377 378 self.set(**kwargs) 379 380 def set(self, **kwargs): 381 for k,v in kwargs.items(): 382 if not hasattr(self, k): 383 raise self.UnknownOptionError("Unknown option '%s'" % k) 384 setattr(self, k, v) 385 386 def parse_opts(self, argv, ignore_unknown=False): 387 ret = [] 388 opts = {} 389 for a in argv: 390 orig_a = a 391 if not a.startswith('--'): 392 ret.append(a) 393 continue 394 a = a[2:] 395 i = a.find('=') 396 op = '=' 397 if i == -1: 398 if a.startswith("no-"): 399 k = a[3:] 400 v = False 401 else: 402 k = a 403 v = True 404 else: 405 k = a[:i] 406 if k[-1] in "-+": 407 op = k[-1]+'=' # Ops is '-=' or '+=' now. 408 k = k[:-1] 409 v = a[i+1:] 410 k = k.replace('-', '_') 411 if not hasattr(self, k): 412 if ignore_unknown == True or k in ignore_unknown: 413 ret.append(orig_a) 414 continue 415 else: 416 raise self.UnknownOptionError("Unknown option '%s'" % a) 417 418 ov = getattr(self, k) 419 if isinstance(ov, bool): 420 v = bool(v) 421 elif isinstance(ov, int): 422 v = int(v) 423 elif isinstance(ov, list): 424 vv = v.split(',') 425 if vv == ['']: 426 vv = [] 427 vv = [int(x, 0) if len(x) and x[0] in "0123456789" else x for x in vv] 428 if op == '=': 429 v = vv 430 elif op == '+=': 431 v = ov 432 v.extend(vv) 433 elif op == '-=': 434 v = ov 435 for x in vv: 436 if x in v: 437 v.remove(x) 438 else: 439 assert 0 440 441 opts[k] = v 442 self.set(**opts) 443 444 return ret 445 446 447class Merger(object): 448 449 def __init__(self, options=None, log=None): 450 451 if not log: 452 log = Logger() 453 if not options: 454 options = Options() 455 456 self.options = options 457 self.log = log 458 459 def merge(self, fontfiles): 460 461 mega = ttLib.TTFont() 462 463 # 464 # Settle on a mega glyph order. 465 # 466 fonts = [ttLib.TTFont(fontfile) for fontfile in fontfiles] 467 glyphOrders = [font.getGlyphOrder() for font in fonts] 468 megaGlyphOrder = self._mergeGlyphOrders(glyphOrders) 469 # Reload fonts and set new glyph names on them. 470 # TODO Is it necessary to reload font? I think it is. At least 471 # it's safer, in case tables were loaded to provide glyph names. 472 fonts = [ttLib.TTFont(fontfile) for fontfile in fontfiles] 473 for font,glyphOrder in zip(fonts, glyphOrders): 474 font.setGlyphOrder(glyphOrder) 475 mega.setGlyphOrder(megaGlyphOrder) 476 477 for font in fonts: 478 self._preMerge(font) 479 480 allTags = reduce(set.union, (list(font.keys()) for font in fonts), set()) 481 allTags.remove('GlyphOrder') 482 for tag in allTags: 483 484 clazz = ttLib.getTableClass(tag) 485 486 tables = [font.get(tag, NotImplemented) for font in fonts] 487 table = clazz(tag).merge(self, tables) 488 if table is not NotImplemented and table is not False: 489 mega[tag] = table 490 self.log("Merged '%s'." % tag) 491 else: 492 self.log("Dropped '%s'." % tag) 493 self.log.lapse("merge '%s'" % tag) 494 495 self._postMerge(mega) 496 497 return mega 498 499 def _mergeGlyphOrders(self, glyphOrders): 500 """Modifies passed-in glyphOrders to reflect new glyph names. 501 Returns glyphOrder for the merged font.""" 502 # Simply append font index to the glyph name for now. 503 # TODO Even this simplistic numbering can result in conflicts. 504 # But then again, we have to improve this soon anyway. 505 mega = [] 506 for n,glyphOrder in enumerate(glyphOrders): 507 for i,glyphName in enumerate(glyphOrder): 508 glyphName += "#" + repr(n) 509 glyphOrder[i] = glyphName 510 mega.append(glyphName) 511 return mega 512 513 def mergeObjects(self, returnTable, logic, tables): 514 # Right now we don't use self at all. Will use in the future 515 # for options and logging. 516 517 if logic is NotImplemented: 518 return NotImplemented 519 520 allKeys = set.union(set(), *(vars(table).keys() for table in tables if table is not NotImplemented)) 521 for key in allKeys: 522 try: 523 mergeLogic = logic[key] 524 except KeyError: 525 try: 526 mergeLogic = logic['*'] 527 except KeyError: 528 raise Exception("Don't know how to merge key %s of class %s" % 529 (key, returnTable.__class__.__name__)) 530 if mergeLogic is NotImplemented: 531 continue 532 value = mergeLogic(getattr(table, key, NotImplemented) for table in tables) 533 if value is not NotImplemented: 534 setattr(returnTable, key, value) 535 536 return returnTable 537 538 def _preMerge(self, font): 539 540 GDEF = font.get('GDEF') 541 GSUB = font.get('GSUB') 542 GPOS = font.get('GPOS') 543 544 for t in [GSUB, GPOS]: 545 if not t: continue 546 547 if t.table.LookupList and t.table.FeatureList: 548 lookupMap = dict(enumerate(t.table.LookupList.Lookup)) 549 t.table.FeatureList.mapLookups(lookupMap) 550 551 if t.table.FeatureList and t.table.ScriptList: 552 featureMap = dict(enumerate(t.table.FeatureList.FeatureRecord)) 553 t.table.ScriptList.mapFeatures(featureMap) 554 555 # TODO GDEF/Lookup MarkFilteringSets 556 # TODO FeatureParams nameIDs 557 558 def _postMerge(self, font): 559 560 GDEF = font.get('GDEF') 561 GSUB = font.get('GSUB') 562 GPOS = font.get('GPOS') 563 564 for t in [GSUB, GPOS]: 565 if not t: continue 566 567 if t.table.LookupList and t.table.FeatureList: 568 lookupMap = dict((v,i) for i,v in enumerate(t.table.LookupList.Lookup)) 569 t.table.FeatureList.mapLookups(lookupMap) 570 571 if t.table.FeatureList and t.table.ScriptList: 572 featureMap = dict((v,i) for i,v in enumerate(t.table.FeatureList.FeatureRecord)) 573 t.table.ScriptList.mapFeatures(featureMap) 574 575 # TODO GDEF/Lookup MarkFilteringSets 576 # TODO FeatureParams nameIDs 577 578 579class Logger(object): 580 581 def __init__(self, verbose=False, xml=False, timing=False): 582 self.verbose = verbose 583 self.xml = xml 584 self.timing = timing 585 self.last_time = self.start_time = time.time() 586 587 def parse_opts(self, argv): 588 argv = argv[:] 589 for v in ['verbose', 'xml', 'timing']: 590 if "--"+v in argv: 591 setattr(self, v, True) 592 argv.remove("--"+v) 593 return argv 594 595 def __call__(self, *things): 596 if not self.verbose: 597 return 598 print(' '.join(str(x) for x in things)) 599 600 def lapse(self, *things): 601 if not self.timing: 602 return 603 new_time = time.time() 604 print("Took %0.3fs to %s" %(new_time - self.last_time, 605 ' '.join(str(x) for x in things))) 606 self.last_time = new_time 607 608 def font(self, font, file=sys.stdout): 609 if not self.xml: 610 return 611 from fontTools.misc import xmlWriter 612 writer = xmlWriter.XMLWriter(file) 613 font.disassembleInstructions = False # Work around ttLib bug 614 for tag in font.keys(): 615 writer.begintag(tag) 616 writer.newline() 617 font[tag].toXML(writer, font) 618 writer.endtag(tag) 619 writer.newline() 620 621 622__all__ = [ 623 'Options', 624 'Merger', 625 'Logger', 626 'main' 627] 628 629def main(args): 630 631 log = Logger() 632 args = log.parse_opts(args) 633 634 options = Options() 635 args = options.parse_opts(args) 636 637 if len(args) < 1: 638 print("usage: pyftmerge font...", file=sys.stderr) 639 sys.exit(1) 640 641 merger = Merger(options=options, log=log) 642 font = merger.merge(args) 643 outfile = 'merged.ttf' 644 font.save(outfile) 645 log.lapse("compile and save font") 646 647 log.last_time = log.start_time 648 log.lapse("make one with everything(TOTAL TIME)") 649 650if __name__ == "__main__": 651 main(sys.argv[1:]) 652