merge.py revision f2d5982826530296fd7c8f9e2d2a4dc3e070934d
1# Copyright 2013 Google, Inc. All Rights Reserved. 2# 3# Google Author(s): Behdad Esfahbod 4 5"""Font merger. 6""" 7 8import sys 9import time 10 11import fontTools 12from fontTools import misc, ttLib, cffLib 13 14def _add_method(*clazzes): 15 """Returns a decorator function that adds a new method to one or 16 more classes.""" 17 def wrapper(method): 18 for clazz in clazzes: 19 assert clazz.__name__ != 'DefaultTable', 'Oops, table class not found.' 20 assert not hasattr(clazz, method.func_name), \ 21 "Oops, class '%s' has method '%s'." % (clazz.__name__, 22 method.func_name) 23 setattr(clazz, method.func_name, method) 24 return None 25 return wrapper 26 27 28@_add_method(fontTools.ttLib.getTableClass('maxp')) 29def merge(self, tables, fonts): 30 # TODO When we correctly merge hinting data, update these values: 31 # maxFunctionDefs, maxInstructionDefs, maxSizeOfInstructions 32 # TODO Assumes that all tables have format 1.0; safe assumption. 33 for key in set(sum((vars(table).keys() for table in tables), [])): 34 setattr(self, key, max(getattr(table, key) for table in tables)) 35 return True 36 37@_add_method(fontTools.ttLib.getTableClass('head')) 38def merge(self, tables, fonts): 39 # TODO Check that unitsPerEm are the same. 40 # TODO Use bitwise ops for flags, macStyle, fontDirectionHint 41 minMembers = ['xMin', 'yMin'] 42 # Negate some members 43 for key in minMembers: 44 for table in tables: 45 setattr(table, key, -getattr(table, key)) 46 # Get max over members 47 for key in set(sum((vars(table).keys() for table in tables), [])): 48 setattr(self, key, max(getattr(table, key) for table in tables)) 49 # Negate them back 50 for key in minMembers: 51 for table in tables: 52 setattr(table, key, -getattr(table, key)) 53 setattr(self, key, -getattr(self, key)) 54 return True 55 56@_add_method(fontTools.ttLib.getTableClass('hhea')) 57def merge(self, tables, fonts): 58 # TODO Check that ascent, descent, slope, etc are the same. 59 minMembers = ['descent', 'minLeftSideBearing', 'minRightSideBearing'] 60 # Negate some members 61 for key in minMembers: 62 for table in tables: 63 setattr(table, key, -getattr(table, key)) 64 # Get max over members 65 for key in set(sum((vars(table).keys() for table in tables), [])): 66 setattr(self, key, max(getattr(table, key) for table in tables)) 67 # Negate them back 68 for key in minMembers: 69 for table in tables: 70 setattr(table, key, -getattr(table, key)) 71 setattr(self, key, -getattr(self, key)) 72 return True 73 74@_add_method(fontTools.ttLib.getTableClass('post')) 75def merge(self, tables, fonts): 76 # TODO Check that italicAngle, underlinePosition, underlineThickness are the same. 77 minMembers = ['underlinePosition', 'minMemType42', 'minMemType1'] 78 # Negate some members 79 for key in minMembers: 80 for table in tables: 81 setattr(table, key, -getattr(table, key)) 82 # Get max over members 83 keys = set(sum((vars(table).keys() for table in tables), [])) 84 if 'mapping' in keys: 85 keys.remove('mapping') 86 keys.remove('extraNames') 87 for key in keys: 88 setattr(self, key, max(getattr(table, key) for table in tables)) 89 # Negate them back 90 for key in minMembers: 91 for table in tables: 92 setattr(table, key, -getattr(table, key)) 93 setattr(self, key, -getattr(self, key)) 94 self.mapping = {} 95 for table in tables: 96 if hasattr(table, 'mapping'): 97 self.mapping.update(table.mapping) 98 self.extraNames = [] 99 return True 100 101@_add_method(fontTools.ttLib.getTableClass('vmtx'), 102 fontTools.ttLib.getTableClass('hmtx')) 103def merge(self, tables, fonts): 104 self.metrics = {} 105 for table in tables: 106 self.metrics.update(table.metrics) 107 return True 108 109@_add_method(fontTools.ttLib.getTableClass('loca')) 110def merge(self, tables, fonts): 111 return True # Will be computed automatically 112 113@_add_method(fontTools.ttLib.getTableClass('glyf')) 114def merge(self, tables, fonts): 115 self.glyphs = {} 116 for table in tables: 117 self.glyphs.update(table.glyphs) 118 # TODO Drop hints? 119 return True 120 121@_add_method(fontTools.ttLib.getTableClass('prep'), 122 fontTools.ttLib.getTableClass('fpgm'), 123 fontTools.ttLib.getTableClass('cvt ')) 124def merge(self, tables, fonts): 125 return False # Will be computed automatically 126 127 128class Options(object): 129 130 class UnknownOptionError(Exception): 131 pass 132 133 _drop_tables_default = ['fpgm', 'prep', 'cvt ', 'gasp'] 134 drop_tables = _drop_tables_default 135 136 def __init__(self, **kwargs): 137 138 self.set(**kwargs) 139 140 def set(self, **kwargs): 141 for k,v in kwargs.iteritems(): 142 if not hasattr(self, k): 143 raise self.UnknownOptionError("Unknown option '%s'" % k) 144 setattr(self, k, v) 145 146 def parse_opts(self, argv, ignore_unknown=False): 147 ret = [] 148 opts = {} 149 for a in argv: 150 orig_a = a 151 if not a.startswith('--'): 152 ret.append(a) 153 continue 154 a = a[2:] 155 i = a.find('=') 156 op = '=' 157 if i == -1: 158 if a.startswith("no-"): 159 k = a[3:] 160 v = False 161 else: 162 k = a 163 v = True 164 else: 165 k = a[:i] 166 if k[-1] in "-+": 167 op = k[-1]+'=' # Ops is '-=' or '+=' now. 168 k = k[:-1] 169 v = a[i+1:] 170 k = k.replace('-', '_') 171 if not hasattr(self, k): 172 if ignore_unknown == True or k in ignore_unknown: 173 ret.append(orig_a) 174 continue 175 else: 176 raise self.UnknownOptionError("Unknown option '%s'" % a) 177 178 ov = getattr(self, k) 179 if isinstance(ov, bool): 180 v = bool(v) 181 elif isinstance(ov, int): 182 v = int(v) 183 elif isinstance(ov, list): 184 vv = v.split(',') 185 if vv == ['']: 186 vv = [] 187 vv = [int(x, 0) if len(x) and x[0] in "0123456789" else x for x in vv] 188 if op == '=': 189 v = vv 190 elif op == '+=': 191 v = ov 192 v.extend(vv) 193 elif op == '-=': 194 v = ov 195 for x in vv: 196 if x in v: 197 v.remove(x) 198 else: 199 assert 0 200 201 opts[k] = v 202 self.set(**opts) 203 204 return ret 205 206 207class Merger: 208 209 def __init__(self, options=None, log=None): 210 211 if not log: 212 log = Logger() 213 if not options: 214 options = Options() 215 216 self.options = options 217 self.log = log 218 219 def merge(self, fontfiles): 220 221 mega = ttLib.TTFont() 222 223 # 224 # Settle on a mega glyph order. 225 # 226 fonts = [ttLib.TTFont(fontfile) for fontfile in fontfiles] 227 glyphOrders = [font.getGlyphOrder() for font in fonts] 228 megaGlyphOrder = self._mergeGlyphOrders(glyphOrders) 229 # Reload fonts and set new glyph names on them. 230 # TODO Is it necessary to reload font? I think it is. At least 231 # it's safer, in case tables were loaded to provide glyph names. 232 fonts = [ttLib.TTFont(fontfile) for fontfile in fontfiles] 233 map(ttLib.TTFont.setGlyphOrder, fonts, glyphOrders) 234 mega.setGlyphOrder(megaGlyphOrder) 235 236 cmaps = [self._get_cmap(font) for font in fonts] 237 238 allTags = set(sum([font.keys() for font in fonts], [])) 239 allTags.remove('GlyphOrder') 240 for tag in allTags: 241 242 if tag in self.options.drop_tables: 243 print "Dropping '%s'." % tag 244 continue 245 246 clazz = ttLib.getTableClass(tag) 247 248 if not hasattr(clazz, 'merge'): 249 print "Don't know how to merge '%s', dropped." % tag 250 continue 251 252 # TODO For now assume all fonts have the same tables. 253 tables = [font[tag] for font in fonts] 254 table = clazz(tag) 255 if table.merge (tables, fonts): 256 mega[tag] = table 257 print "Merged '%s'." % tag 258 else: 259 print "Dropped '%s'. No need to merge explicitly." % tag 260 261 return mega 262 263 def _get_cmap(self, font): 264 cmap = font['cmap'] 265 tables = [t for t in cmap.tables 266 if t.platformID == 3 and t.platEncID in [1, 10]] 267 # XXX Handle format=14 268 assert len(tables) 269 # Pick table that has largest coverage 270 table = max(tables, key=lambda t: len(t.cmap)) 271 return table 272 273 def _mergeGlyphOrders(self, glyphOrders): 274 """Modifies passed-in glyphOrders to reflect new glyph names.""" 275 # Simply append font index to the glyph name for now. 276 mega = [] 277 for n,glyphOrder in enumerate(glyphOrders): 278 for i,glyphName in enumerate(glyphOrder): 279 glyphName += "#" + `n` 280 glyphOrder[i] = glyphName 281 mega.append(glyphName) 282 return mega 283 284 285class Logger(object): 286 287 def __init__(self, verbose=False, xml=False, timing=False): 288 self.verbose = verbose 289 self.xml = xml 290 self.timing = timing 291 self.last_time = self.start_time = time.time() 292 293 def parse_opts(self, argv): 294 argv = argv[:] 295 for v in ['verbose', 'xml', 'timing']: 296 if "--"+v in argv: 297 setattr(self, v, True) 298 argv.remove("--"+v) 299 return argv 300 301 def __call__(self, *things): 302 if not self.verbose: 303 return 304 print ' '.join(str(x) for x in things) 305 306 def lapse(self, *things): 307 if not self.timing: 308 return 309 new_time = time.time() 310 print "Took %0.3fs to %s" %(new_time - self.last_time, 311 ' '.join(str(x) for x in things)) 312 self.last_time = new_time 313 314 def font(self, font, file=sys.stdout): 315 if not self.xml: 316 return 317 from fontTools.misc import xmlWriter 318 writer = xmlWriter.XMLWriter(file) 319 font.disassembleInstructions = False # Work around ttLib bug 320 for tag in font.keys(): 321 writer.begintag(tag) 322 writer.newline() 323 font[tag].toXML(writer, font) 324 writer.endtag(tag) 325 writer.newline() 326 327 328__all__ = [ 329 'Options', 330 'Merger', 331 'Logger', 332 'main' 333] 334 335def main(args): 336 337 log = Logger() 338 args = log.parse_opts(args) 339 340 options = Options() 341 args = options.parse_opts(args) 342 343 if len(args) < 1: 344 print >>sys.stderr, "usage: pyftmerge font..." 345 sys.exit(1) 346 347 merger = Merger(options=options, log=log) 348 font = merger.merge(args) 349 outfile = 'merged.ttf' 350 font.save(outfile) 351 352if __name__ == "__main__": 353 main(sys.argv[1:]) 354