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