merge.py revision 47bee9cfbd47dc22895003cc94ab91f9075ca27f
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 12from functools import reduce 13import sys 14import time 15 16 17def _add_method(*clazzes): 18 """Returns a decorator function that adds a new method to one or 19 more classes.""" 20 def wrapper(method): 21 for clazz in clazzes: 22 assert clazz.__name__ != 'DefaultTable', 'Oops, table class not found.' 23 assert not hasattr(clazz, method.__name__), \ 24 "Oops, class '%s' has method '%s'." % (clazz.__name__, 25 method.__name__) 26 setattr(clazz, method.__name__, method) 27 return None 28 return wrapper 29 30# General utility functions for merging values from different fonts 31def assert_equal(lst): 32 first = lst[0] 33 assert all([item == first for item in lst]) 34 35def first(lst): 36 return lst[0] 37 38 39@_add_method(ttLib.getTableClass('maxp')) 40def merge(self, m): 41 logic = { 42 '*': max, 43 'tableVersion': assert_equal, 44 'numGlyphs': sum, 45 'maxStorage': max, # FIXME: may need to be changed to sum 46 'maxFunctionDefs': sum, 47 'maxInstructionDefs': sum, 48 } 49 # TODO When we correctly merge hinting data, update these values: 50 # maxFunctionDefs, maxInstructionDefs, maxSizeOfInstructions 51 m._mergeKeys(self, logic) 52 return True 53 54@_add_method(ttLib.getTableClass('head')) 55def merge(self, m): 56 # TODO Check that unitsPerEm are the same. 57 # TODO Use bitwise ops for flags, macStyle, fontDirectionHint 58 minMembers = ['xMin', 'yMin'] 59 # Negate some members 60 for key in minMembers: 61 for table in m.tables: 62 setattr(table, key, -getattr(table, key)) 63 # Get max over members 64 allKeys = reduce(set.union, (list(vars(table).keys()) for table in m.tables), set()) 65 for key in allKeys: 66 setattr(self, key, max(getattr(table, key) for table in m.tables)) 67 # Negate them back 68 for key in minMembers: 69 for table in m.tables: 70 setattr(table, key, -getattr(table, key)) 71 setattr(self, key, -getattr(self, key)) 72 return True 73 74@_add_method(ttLib.getTableClass('hhea')) 75def merge(self, m): 76 # TODO Check that ascent, descent, slope, etc are the same. 77 minMembers = ['descent', 'minLeftSideBearing', 'minRightSideBearing'] 78 # Negate some members 79 for key in minMembers: 80 for table in m.tables: 81 setattr(table, key, -getattr(table, key)) 82 # Get max over members 83 allKeys = reduce(set.union, (list(vars(table).keys()) for table in m.tables), set()) 84 for key in allKeys: 85 setattr(self, key, max(getattr(table, key) for table in m.tables)) 86 # Negate them back 87 for key in minMembers: 88 for table in m.tables: 89 setattr(table, key, -getattr(table, key)) 90 setattr(self, key, -getattr(self, key)) 91 return True 92 93@_add_method(ttLib.getTableClass('OS/2')) 94def merge(self, m): 95 # TODO Check that weight/width/subscript/superscript/etc are the same. 96 # TODO Bitwise ops for UnicodeRange/CodePageRange. 97 # TODO Pretty much all fields generated here have bogus values. 98 # Get max over members 99 allKeys = reduce(set.union, (list(vars(table).keys()) for table in m.tables), set()) 100 for key in allKeys: 101 setattr(self, key, max(getattr(table, key) for table in m.tables)) 102 return True 103 104@_add_method(ttLib.getTableClass('post')) 105def merge(self, m): 106 # TODO Check that italicAngle, underlinePosition, underlineThickness are the same. 107 minMembers = ['underlinePosition', 'minMemType42', 'minMemType1'] 108 # Negate some members 109 for key in minMembers: 110 for table in m.tables: 111 setattr(table, key, -getattr(table, key)) 112 # Get max over members 113 allKeys = reduce(set.union, (list(vars(table).keys()) for table in m.tables), set()) 114 if 'mapping' in allKeys: 115 allKeys.remove('mapping') 116 allKeys.remove('extraNames') 117 for key in allKeys: 118 setattr(self, key, max(getattr(table, key) for table in m.tables)) 119 # Negate them back 120 for key in minMembers: 121 for table in m.tables: 122 setattr(table, key, -getattr(table, key)) 123 setattr(self, key, -getattr(self, key)) 124 self.mapping = {} 125 for table in m.tables: 126 if hasattr(table, 'mapping'): 127 self.mapping.update(table.mapping) 128 self.extraNames = [] 129 return True 130 131@_add_method(ttLib.getTableClass('vmtx'), 132 ttLib.getTableClass('hmtx')) 133def merge(self, m): 134 self.metrics = {} 135 for table in m.tables: 136 self.metrics.update(table.metrics) 137 return True 138 139@_add_method(ttLib.getTableClass('loca')) 140def merge(self, m): 141 return True # Will be computed automatically 142 143@_add_method(ttLib.getTableClass('glyf')) 144def merge(self, m): 145 self.glyphs = {} 146 for table in m.tables: 147 for g in table.glyphs.values(): 148 # Drop hints for now, since we don't remap 149 # functions / CVT values. 150 g.removeHinting() 151 # Expand composite glyphs to load their 152 # composite glyph names. 153 if g.isComposite(): 154 g.expand(table) 155 self.glyphs.update(table.glyphs) 156 return True 157 158@_add_method(ttLib.getTableClass('prep'), 159 ttLib.getTableClass('fpgm'), 160 ttLib.getTableClass('cvt ')) 161def merge(self, m): 162 return False # TODO We don't merge hinting data currently. 163 164@_add_method(ttLib.getTableClass('cmap')) 165def merge(self, m): 166 # TODO Handle format=14. 167 cmapTables = [t for table in m.tables for t in table.tables 168 if t.platformID == 3 and t.platEncID in [1, 10]] 169 # TODO Better handle format-4 and format-12 coexisting in same font. 170 # TODO Insert both a format-4 and format-12 if needed. 171 module = ttLib.getTableModule('cmap') 172 assert all(t.format in [4, 12] for t in cmapTables) 173 format = max(t.format for t in cmapTables) 174 cmapTable = module.cmap_classes[format](format) 175 cmapTable.cmap = {} 176 cmapTable.platformID = 3 177 cmapTable.platEncID = max(t.platEncID for t in cmapTables) 178 cmapTable.language = 0 179 for table in cmapTables: 180 # TODO handle duplicates. 181 cmapTable.cmap.update(table.cmap) 182 self.tableVersion = 0 183 self.tables = [cmapTable] 184 self.numSubTables = len(self.tables) 185 return True 186 187@_add_method(ttLib.getTableClass('GDEF')) 188def merge(self, m): 189 self.table = otTables.GDEF() 190 self.table.Version = 1.0 # TODO version 1.2... 191 192 if any(t.table.LigCaretList for t in m.tables): 193 glyphs = [] 194 ligGlyphs = [] 195 for table in m.tables: 196 if table.table.LigCaretList: 197 glyphs.extend(table.table.LigCaretList.Coverage.glyphs) 198 ligGlyphs.extend(table.table.LigCaretList.LigGlyph) 199 coverage = otTables.Coverage() 200 coverage.glyphs = glyphs 201 ligCaretList = otTables.LigCaretList() 202 ligCaretList.Coverage = coverage 203 ligCaretList.LigGlyph = ligGlyphs 204 ligCaretList.GlyphCount = len(ligGlyphs) 205 self.table.LigCaretList = ligCaretList 206 else: 207 self.table.LigCaretList = None 208 209 if any(t.table.MarkAttachClassDef for t in m.tables): 210 classDefs = {} 211 for table in m.tables: 212 if table.table.MarkAttachClassDef: 213 classDefs.update(table.table.MarkAttachClassDef.classDefs) 214 self.table.MarkAttachClassDef = otTables.MarkAttachClassDef() 215 self.table.MarkAttachClassDef.classDefs = classDefs 216 else: 217 self.table.MarkAttachClassDef = None 218 219 if any(t.table.GlyphClassDef for t in m.tables): 220 classDefs = {} 221 for table in m.tables: 222 if table.table.GlyphClassDef: 223 classDefs.update(table.table.GlyphClassDef.classDefs) 224 self.table.GlyphClassDef = otTables.GlyphClassDef() 225 self.table.GlyphClassDef.classDefs = classDefs 226 else: 227 self.table.GlyphClassDef = None 228 229 if any(t.table.AttachList for t in m.tables): 230 glyphs = [] 231 attachPoints = [] 232 for table in m.tables: 233 if table.table.AttachList: 234 glyphs.extend(table.table.AttachList.Coverage.glyphs) 235 attachPoints.extend(table.table.AttachList.AttachPoint) 236 coverage = otTables.Coverage() 237 coverage.glyphs = glyphs 238 attachList = otTables.AttachList() 239 attachList.Coverage = coverage 240 attachList.AttachPoint = attachPoints 241 attachList.GlyphCount = len(attachPoints) 242 self.table.AttachList = attachList 243 else: 244 self.table.AttachList = None 245 246 return True 247 248 249class Options(object): 250 251 class UnknownOptionError(Exception): 252 pass 253 254 _drop_tables_default = ['fpgm', 'prep', 'cvt ', 'gasp'] 255 drop_tables = _drop_tables_default 256 257 def __init__(self, **kwargs): 258 259 self.set(**kwargs) 260 261 def set(self, **kwargs): 262 for k,v in kwargs.items(): 263 if not hasattr(self, k): 264 raise self.UnknownOptionError("Unknown option '%s'" % k) 265 setattr(self, k, v) 266 267 def parse_opts(self, argv, ignore_unknown=False): 268 ret = [] 269 opts = {} 270 for a in argv: 271 orig_a = a 272 if not a.startswith('--'): 273 ret.append(a) 274 continue 275 a = a[2:] 276 i = a.find('=') 277 op = '=' 278 if i == -1: 279 if a.startswith("no-"): 280 k = a[3:] 281 v = False 282 else: 283 k = a 284 v = True 285 else: 286 k = a[:i] 287 if k[-1] in "-+": 288 op = k[-1]+'=' # Ops is '-=' or '+=' now. 289 k = k[:-1] 290 v = a[i+1:] 291 k = k.replace('-', '_') 292 if not hasattr(self, k): 293 if ignore_unknown == True or k in ignore_unknown: 294 ret.append(orig_a) 295 continue 296 else: 297 raise self.UnknownOptionError("Unknown option '%s'" % a) 298 299 ov = getattr(self, k) 300 if isinstance(ov, bool): 301 v = bool(v) 302 elif isinstance(ov, int): 303 v = int(v) 304 elif isinstance(ov, list): 305 vv = v.split(',') 306 if vv == ['']: 307 vv = [] 308 vv = [int(x, 0) if len(x) and x[0] in "0123456789" else x for x in vv] 309 if op == '=': 310 v = vv 311 elif op == '+=': 312 v = ov 313 v.extend(vv) 314 elif op == '-=': 315 v = ov 316 for x in vv: 317 if x in v: 318 v.remove(x) 319 else: 320 assert 0 321 322 opts[k] = v 323 self.set(**opts) 324 325 return ret 326 327 328class Merger: 329 330 def __init__(self, options=None, log=None): 331 332 if not log: 333 log = Logger() 334 if not options: 335 options = Options() 336 337 self.options = options 338 self.log = log 339 340 def merge(self, fontfiles): 341 342 mega = ttLib.TTFont() 343 344 # 345 # Settle on a mega glyph order. 346 # 347 fonts = [ttLib.TTFont(fontfile) for fontfile in fontfiles] 348 glyphOrders = [font.getGlyphOrder() for font in fonts] 349 megaGlyphOrder = self._mergeGlyphOrders(glyphOrders) 350 # Reload fonts and set new glyph names on them. 351 # TODO Is it necessary to reload font? I think it is. At least 352 # it's safer, in case tables were loaded to provide glyph names. 353 fonts = [ttLib.TTFont(fontfile) for fontfile in fontfiles] 354 for font,glyphOrder in zip(fonts, glyphOrders): 355 font.setGlyphOrder(glyphOrder) 356 mega.setGlyphOrder(megaGlyphOrder) 357 358 allTags = reduce(set.union, (list(font.keys()) for font in fonts), set()) 359 allTags.remove('GlyphOrder') 360 for tag in allTags: 361 362 if tag in self.options.drop_tables: 363 self.log("Dropping '%s'." % tag) 364 continue 365 366 clazz = ttLib.getTableClass(tag) 367 368 if not hasattr(clazz, 'merge'): 369 self.log("Don't know how to merge '%s', dropped." % tag) 370 continue 371 372 # TODO For now assume all fonts have the same tables. 373 self.tables = [font[tag] for font in fonts] 374 table = clazz(tag) 375 if table.merge (self): 376 mega[tag] = table 377 self.log("Merged '%s'." % tag) 378 else: 379 self.log("Dropped '%s'. No need to merge explicitly." % tag) 380 self.log.lapse("merge '%s'" % tag) 381 del self.tables 382 383 return mega 384 385 def _mergeGlyphOrders(self, glyphOrders): 386 """Modifies passed-in glyphOrders to reflect new glyph names. 387 Returns glyphOrder for the merged font.""" 388 # Simply append font index to the glyph name for now. 389 # TODO Even this simplistic numbering can result in conflicts. 390 # But then again, we have to improve this soon anyway. 391 mega = [] 392 for n,glyphOrder in enumerate(glyphOrders): 393 for i,glyphName in enumerate(glyphOrder): 394 glyphName += "#" + repr(n) 395 glyphOrder[i] = glyphName 396 mega.append(glyphName) 397 return mega 398 399 def _mergeKeys(self, return_table, logic): 400 logic['tableTag'] = assert_equal 401 allKeys = set.union(set(), *(vars(table).keys() for table in self.tables)) 402 for key in allKeys: 403 merge_logic = logic.get(key, logic['*']) 404 key_value = merge_logic([getattr(table, key) for table in self.tables]) 405 setattr(return_table, key, key_value) 406 407 408class Logger(object): 409 410 def __init__(self, verbose=False, xml=False, timing=False): 411 self.verbose = verbose 412 self.xml = xml 413 self.timing = timing 414 self.last_time = self.start_time = time.time() 415 416 def parse_opts(self, argv): 417 argv = argv[:] 418 for v in ['verbose', 'xml', 'timing']: 419 if "--"+v in argv: 420 setattr(self, v, True) 421 argv.remove("--"+v) 422 return argv 423 424 def __call__(self, *things): 425 if not self.verbose: 426 return 427 print(' '.join(str(x) for x in things)) 428 429 def lapse(self, *things): 430 if not self.timing: 431 return 432 new_time = time.time() 433 print("Took %0.3fs to %s" %(new_time - self.last_time, 434 ' '.join(str(x) for x in things))) 435 self.last_time = new_time 436 437 def font(self, font, file=sys.stdout): 438 if not self.xml: 439 return 440 from fontTools.misc import xmlWriter 441 writer = xmlWriter.XMLWriter(file) 442 font.disassembleInstructions = False # Work around ttLib bug 443 for tag in font.keys(): 444 writer.begintag(tag) 445 writer.newline() 446 font[tag].toXML(writer, font) 447 writer.endtag(tag) 448 writer.newline() 449 450 451__all__ = [ 452 'Options', 453 'Merger', 454 'Logger', 455 'main' 456] 457 458def main(args): 459 460 log = Logger() 461 args = log.parse_opts(args) 462 463 options = Options() 464 args = options.parse_opts(args) 465 466 if len(args) < 1: 467 print("usage: pyftmerge font...", file=sys.stderr) 468 sys.exit(1) 469 470 merger = Merger(options=options, log=log) 471 font = merger.merge(args) 472 outfile = 'merged.ttf' 473 font.save(outfile) 474 log.lapse("compile and save font") 475 476 log.last_time = log.start_time 477 log.lapse("make one with everything(TOTAL TIME)") 478 479if __name__ == "__main__": 480 main(sys.argv[1:]) 481