merge.py revision 6baf26ea74247a0bed5f0b52d13bc3ef1c931204
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 def wrapper(method): 23 for clazz in clazzes: 24 if not kwargs.get('allowDefaultTable', False): 25 assert 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 34def equal(lst): 35 t = iter(lst) 36 first = next(t) 37 assert all(item == first for item in t) 38 return first 39 40def first(lst): 41 return next(iter(lst)) 42 43def recalculate(lst): 44 # Just return the first value, assume will be recalculated when saved 45 return first(lst) 46 47def current_time(lst): 48 return int(time.time() - _h_e_a_d.mac_epoch_diff) 49 50def bitwise_or(lst): 51 return reduce(operator.or_, lst) 52 53def ignore(lst): 54 assert False, "This function should not be called." 55 56def maybenone(func): 57 """Returns a filter func that when called with a list, 58 only calls func on the non-None items of the list, and 59 only so if there's at least one non-None item in the 60 list.""" 61 62 def wrapper(lst): 63 items = [item for item in lst if item is not None] 64 return func(items) if items else None 65 66 return wrapper 67 68def sumLists(lst): 69 l = [] 70 for item in lst: 71 l.extend(item) 72 return l 73 74def sumDicts(lst): 75 d = {} 76 for item in lst: 77 d.update(item) 78 return d 79 80 81@_add_method(DefaultTable, allowDefaultTable=True) 82def merge(self, m, tables): 83 if not hasattr(self, 'mergeMap'): 84 m.log("Don't know how to merge '%s'." % self.tableTag) 85 return False 86 87 m.mergeObjects(self, self.mergeMap, tables) 88 return True 89 90ttLib.getTableClass('maxp').mergeMap = { 91 '*': max, 92 'tableTag': equal, 93 'tableVersion': equal, 94 'numGlyphs': sum, 95 'maxStorage': max, # FIXME: may need to be changed to sum 96 'maxFunctionDefs': sum, 97 'maxInstructionDefs': sum, 98 # TODO When we correctly merge hinting data, update these values: 99 # maxFunctionDefs, maxInstructionDefs, maxSizeOfInstructions 100} 101 102ttLib.getTableClass('head').mergeMap = { 103 'tableTag': equal, 104 'tableVersion': max, 105 'fontRevision': max, 106 'checkSumAdjustment': recalculate, 107 'magicNumber': equal, 108 'flags': first, # FIXME: replace with bit-sensitive code 109 'unitsPerEm': equal, 110 'created': current_time, 111 'modified': current_time, 112 'xMin': min, 113 'yMin': min, 114 'xMax': max, 115 'yMax': max, 116 'macStyle': first, 117 'lowestRecPPEM': max, 118 'fontDirectionHint': lambda lst: 2, 119 'indexToLocFormat': recalculate, 120 'glyphDataFormat': equal, 121} 122 123ttLib.getTableClass('hhea').mergeMap = { 124 '*': equal, 125 'tableTag': equal, 126 'tableVersion': max, 127 'ascent': max, 128 'descent': min, 129 'lineGap': max, 130 'advanceWidthMax': max, 131 'minLeftSideBearing': min, 132 'minRightSideBearing': min, 133 'xMaxExtent': max, 134 'caretSlopeRise': first, # FIXME 135 'caretSlopeRun': first, # FIXME 136 'caretOffset': first, # FIXME 137 'numberOfHMetrics': recalculate, 138} 139 140ttLib.getTableClass('OS/2').mergeMap = { 141 '*': first, 142 'tableTag': equal, 143 'version': max, 144 'xAvgCharWidth': recalculate, 145 'fsType': first, # FIXME 146 'panose': first, # FIXME? 147 'ulUnicodeRange1': bitwise_or, 148 'ulUnicodeRange2': bitwise_or, 149 'ulUnicodeRange3': bitwise_or, 150 'ulUnicodeRange4': bitwise_or, 151 'fsFirstCharIndex': min, 152 'fsLastCharIndex': max, 153 'sTypoAscender': max, 154 'sTypoDescender': min, 155 'sTypoLineGap': max, 156 'usWinAscent': max, 157 'usWinDescent': max, 158 'ulCodePageRange1': bitwise_or, 159 'ulCodePageRange2': bitwise_or, 160 'usMaxContex': max, 161 # TODO version 5 162} 163 164ttLib.getTableClass('post').mergeMap = { 165 '*': first, 166 'tableTag': equal, 167 'formatType': max, 168 'isFixedPitch': min, 169 'minMemType42': max, 170 'maxMemType42': lambda lst: 0, 171 'minMemType1': max, 172 'maxMemType1': lambda lst: 0, 173 'mapping': ignore, 174 'extraNames': ignore, 175} 176@_add_method(ttLib.getTableClass('post')) 177def merge(self, m, tables): 178 DefaultTable.merge(self, m, tables) 179 self.mapping = {} 180 for table in tables: 181 if hasattr(table, 'mapping'): 182 self.mapping.update(table.mapping) 183 self.extraNames = [] 184 return True 185 186ttLib.getTableClass('vmtx').mergeMap = ttLib.getTableClass('hmtx').mergeMap = { 187 'tableTag': equal, 188 'metrics': sumDicts, 189} 190 191ttLib.getTableClass('loca').mergeMap = { 192 '*': ignore, 193 'tableTag': equal, 194} 195 196ttLib.getTableClass('glyf').mergeMap = { 197 'tableTag': equal, 198 'glyphs': sumDicts, 199 'glyphOrder': sumLists, 200} 201 202@_add_method(ttLib.getTableClass('glyf')) 203def merge(self, m, tables): 204 for table in tables: 205 for g in table.glyphs.values(): 206 # Drop hints for now, since we don't remap 207 # functions / CVT values. 208 g.removeHinting() 209 # Expand composite glyphs to load their 210 # composite glyph names. 211 if g.isComposite(): 212 g.expand(table) 213 DefaultTable.merge(self, m, tables) 214 return True 215 216@_add_method(ttLib.getTableClass('prep'), 217 ttLib.getTableClass('fpgm'), 218 ttLib.getTableClass('cvt ')) 219def merge(self, m): 220 return False # TODO We don't merge hinting data currently. 221 222@_add_method(ttLib.getTableClass('cmap')) 223def merge(self, m, tables): 224 # TODO Handle format=14. 225 cmapTables = [t for table in tables for t in table.tables 226 if t.platformID == 3 and t.platEncID in [1, 10]] 227 # TODO Better handle format-4 and format-12 coexisting in same font. 228 # TODO Insert both a format-4 and format-12 if needed. 229 module = ttLib.getTableModule('cmap') 230 assert all(t.format in [4, 12] for t in cmapTables) 231 format = max(t.format for t in cmapTables) 232 cmapTable = module.cmap_classes[format](format) 233 cmapTable.cmap = {} 234 cmapTable.platformID = 3 235 cmapTable.platEncID = max(t.platEncID for t in cmapTables) 236 cmapTable.language = 0 237 for table in cmapTables: 238 # TODO handle duplicates. 239 cmapTable.cmap.update(table.cmap) 240 self.tableVersion = 0 241 self.tables = [cmapTable] 242 self.numSubTables = len(self.tables) 243 return True 244 245@_add_method(ttLib.getTableClass('GDEF')) 246def merge(self, m, tables): 247 self.table = otTables.GDEF() 248 self.table.Version = 1.0 # TODO version 1.2... 249 250 if any(t.table.LigCaretList for t in tables): 251 glyphs = [] 252 ligGlyphs = [] 253 for table in tables: 254 if table.table.LigCaretList: 255 glyphs.extend(table.table.LigCaretList.Coverage.glyphs) 256 ligGlyphs.extend(table.table.LigCaretList.LigGlyph) 257 coverage = otTables.Coverage() 258 coverage.glyphs = glyphs 259 ligCaretList = otTables.LigCaretList() 260 ligCaretList.Coverage = coverage 261 ligCaretList.LigGlyph = ligGlyphs 262 ligCaretList.GlyphCount = len(ligGlyphs) 263 self.table.LigCaretList = ligCaretList 264 else: 265 self.table.LigCaretList = None 266 267 if any(t.table.MarkAttachClassDef for t in tables): 268 classDefs = {} 269 for table in tables: 270 if table.table.MarkAttachClassDef: 271 classDefs.update(table.table.MarkAttachClassDef.classDefs) 272 self.table.MarkAttachClassDef = otTables.MarkAttachClassDef() 273 self.table.MarkAttachClassDef.classDefs = classDefs 274 else: 275 self.table.MarkAttachClassDef = None 276 277 if any(t.table.GlyphClassDef for t in tables): 278 classDefs = {} 279 for table in tables: 280 if table.table.GlyphClassDef: 281 classDefs.update(table.table.GlyphClassDef.classDefs) 282 self.table.GlyphClassDef = otTables.GlyphClassDef() 283 self.table.GlyphClassDef.classDefs = classDefs 284 else: 285 self.table.GlyphClassDef = None 286 287 if any(t.table.AttachList for t in tables): 288 glyphs = [] 289 attachPoints = [] 290 for table in tables: 291 if table.table.AttachList: 292 glyphs.extend(table.table.AttachList.Coverage.glyphs) 293 attachPoints.extend(table.table.AttachList.AttachPoint) 294 coverage = otTables.Coverage() 295 coverage.glyphs = glyphs 296 attachList = otTables.AttachList() 297 attachList.Coverage = coverage 298 attachList.AttachPoint = attachPoints 299 attachList.GlyphCount = len(attachPoints) 300 self.table.AttachList = attachList 301 else: 302 self.table.AttachList = None 303 304 return True 305 306 307class Options(object): 308 309 class UnknownOptionError(Exception): 310 pass 311 312 _drop_tables_default = ['fpgm', 'prep', 'cvt ', 'gasp'] 313 drop_tables = _drop_tables_default 314 315 def __init__(self, **kwargs): 316 317 self.set(**kwargs) 318 319 def set(self, **kwargs): 320 for k,v in kwargs.items(): 321 if not hasattr(self, k): 322 raise self.UnknownOptionError("Unknown option '%s'" % k) 323 setattr(self, k, v) 324 325 def parse_opts(self, argv, ignore_unknown=False): 326 ret = [] 327 opts = {} 328 for a in argv: 329 orig_a = a 330 if not a.startswith('--'): 331 ret.append(a) 332 continue 333 a = a[2:] 334 i = a.find('=') 335 op = '=' 336 if i == -1: 337 if a.startswith("no-"): 338 k = a[3:] 339 v = False 340 else: 341 k = a 342 v = True 343 else: 344 k = a[:i] 345 if k[-1] in "-+": 346 op = k[-1]+'=' # Ops is '-=' or '+=' now. 347 k = k[:-1] 348 v = a[i+1:] 349 k = k.replace('-', '_') 350 if not hasattr(self, k): 351 if ignore_unknown == True or k in ignore_unknown: 352 ret.append(orig_a) 353 continue 354 else: 355 raise self.UnknownOptionError("Unknown option '%s'" % a) 356 357 ov = getattr(self, k) 358 if isinstance(ov, bool): 359 v = bool(v) 360 elif isinstance(ov, int): 361 v = int(v) 362 elif isinstance(ov, list): 363 vv = v.split(',') 364 if vv == ['']: 365 vv = [] 366 vv = [int(x, 0) if len(x) and x[0] in "0123456789" else x for x in vv] 367 if op == '=': 368 v = vv 369 elif op == '+=': 370 v = ov 371 v.extend(vv) 372 elif op == '-=': 373 v = ov 374 for x in vv: 375 if x in v: 376 v.remove(x) 377 else: 378 assert 0 379 380 opts[k] = v 381 self.set(**opts) 382 383 return ret 384 385 386class Merger(object): 387 388 def __init__(self, options=None, log=None): 389 390 if not log: 391 log = Logger() 392 if not options: 393 options = Options() 394 395 self.options = options 396 self.log = log 397 398 def merge(self, fontfiles): 399 400 mega = ttLib.TTFont() 401 402 # 403 # Settle on a mega glyph order. 404 # 405 fonts = [ttLib.TTFont(fontfile) for fontfile in fontfiles] 406 glyphOrders = [font.getGlyphOrder() for font in fonts] 407 megaGlyphOrder = self._mergeGlyphOrders(glyphOrders) 408 # Reload fonts and set new glyph names on them. 409 # TODO Is it necessary to reload font? I think it is. At least 410 # it's safer, in case tables were loaded to provide glyph names. 411 fonts = [ttLib.TTFont(fontfile) for fontfile in fontfiles] 412 for font,glyphOrder in zip(fonts, glyphOrders): 413 font.setGlyphOrder(glyphOrder) 414 mega.setGlyphOrder(megaGlyphOrder) 415 416 allTags = reduce(set.union, (list(font.keys()) for font in fonts), set()) 417 allTags.remove('GlyphOrder') 418 for tag in allTags: 419 420 if tag in self.options.drop_tables: 421 self.log("Dropping '%s'." % tag) 422 continue 423 424 clazz = ttLib.getTableClass(tag) 425 426 # TODO For now assume all fonts have the same tables. 427 tables = [font[tag] for font in fonts] 428 table = clazz(tag) 429 if table.merge (self, tables): 430 mega[tag] = table 431 self.log("Merged '%s'." % tag) 432 else: 433 self.log("Dropped '%s'." % tag) 434 self.log.lapse("merge '%s'" % tag) 435 436 return mega 437 438 def _mergeGlyphOrders(self, glyphOrders): 439 """Modifies passed-in glyphOrders to reflect new glyph names. 440 Returns glyphOrder for the merged font.""" 441 # Simply append font index to the glyph name for now. 442 # TODO Even this simplistic numbering can result in conflicts. 443 # But then again, we have to improve this soon anyway. 444 mega = [] 445 for n,glyphOrder in enumerate(glyphOrders): 446 for i,glyphName in enumerate(glyphOrder): 447 glyphName += "#" + repr(n) 448 glyphOrder[i] = glyphName 449 mega.append(glyphName) 450 return mega 451 452 def mergeObjects(self, returnTable, logic, tables): 453 allKeys = set.union(set(), *(vars(table).keys() for table in tables)) 454 for key in allKeys: 455 try: 456 mergeLogic = logic[key] 457 except KeyError: 458 try: 459 mergeLogic = logic['*'] 460 except KeyError: 461 raise Exception("Don't know how to merge key %s of class %s" % 462 (key, returnTable.__class__.__name__)) 463 if mergeLogic == ignore: 464 continue 465 key_value = mergeLogic(getattr(table, key) for table in tables) 466 setattr(returnTable, key, key_value) 467 468 469class Logger(object): 470 471 def __init__(self, verbose=False, xml=False, timing=False): 472 self.verbose = verbose 473 self.xml = xml 474 self.timing = timing 475 self.last_time = self.start_time = time.time() 476 477 def parse_opts(self, argv): 478 argv = argv[:] 479 for v in ['verbose', 'xml', 'timing']: 480 if "--"+v in argv: 481 setattr(self, v, True) 482 argv.remove("--"+v) 483 return argv 484 485 def __call__(self, *things): 486 if not self.verbose: 487 return 488 print(' '.join(str(x) for x in things)) 489 490 def lapse(self, *things): 491 if not self.timing: 492 return 493 new_time = time.time() 494 print("Took %0.3fs to %s" %(new_time - self.last_time, 495 ' '.join(str(x) for x in things))) 496 self.last_time = new_time 497 498 def font(self, font, file=sys.stdout): 499 if not self.xml: 500 return 501 from fontTools.misc import xmlWriter 502 writer = xmlWriter.XMLWriter(file) 503 font.disassembleInstructions = False # Work around ttLib bug 504 for tag in font.keys(): 505 writer.begintag(tag) 506 writer.newline() 507 font[tag].toXML(writer, font) 508 writer.endtag(tag) 509 writer.newline() 510 511 512__all__ = [ 513 'Options', 514 'Merger', 515 'Logger', 516 'main' 517] 518 519def main(args): 520 521 log = Logger() 522 args = log.parse_opts(args) 523 524 options = Options() 525 args = options.parse_opts(args) 526 527 if len(args) < 1: 528 print("usage: pyftmerge font...", file=sys.stderr) 529 sys.exit(1) 530 531 merger = Merger(options=options, log=log) 532 font = merger.merge(args) 533 outfile = 'merged.ttf' 534 font.save(outfile) 535 log.lapse("compile and save font") 536 537 log.last_time = log.start_time 538 log.lapse("make one with everything(TOTAL TIME)") 539 540if __name__ == "__main__": 541 main(sys.argv[1:]) 542