1from __future__ import print_function, division, absolute_import 2from fontTools.misc.py23 import * 3from fontTools.misc.fixedTools import otRound 4from fontTools.ttLib.tables import otTables as ot 5from fontTools.varLib.models import supportScalar 6from fontTools.varLib.builder import (buildVarRegionList, buildVarStore, 7 buildVarRegion, buildVarData) 8from functools import partial 9from collections import defaultdict 10from array import array 11 12 13def _getLocationKey(loc): 14 return tuple(sorted(loc.items(), key=lambda kv: kv[0])) 15 16 17class OnlineVarStoreBuilder(object): 18 19 def __init__(self, axisTags): 20 self._axisTags = axisTags 21 self._regionMap = {} 22 self._regionList = buildVarRegionList([], axisTags) 23 self._store = buildVarStore(self._regionList, []) 24 self._data = None 25 self._model = None 26 self._supports = None 27 self._varDataIndices = {} 28 self._varDataCaches = {} 29 self._cache = {} 30 31 def setModel(self, model): 32 self.setSupports(model.supports) 33 self._model = model 34 35 def setSupports(self, supports): 36 self._model = None 37 self._supports = list(supports) 38 if not self._supports[0]: 39 del self._supports[0] # Drop base master support 40 self._cache = {} 41 self._data = None 42 43 def finish(self, optimize=True): 44 self._regionList.RegionCount = len(self._regionList.Region) 45 self._store.VarDataCount = len(self._store.VarData) 46 for data in self._store.VarData: 47 data.ItemCount = len(data.Item) 48 data.calculateNumShorts(optimize=optimize) 49 return self._store 50 51 def _add_VarData(self): 52 regionMap = self._regionMap 53 regionList = self._regionList 54 55 regions = self._supports 56 regionIndices = [] 57 for region in regions: 58 key = _getLocationKey(region) 59 idx = regionMap.get(key) 60 if idx is None: 61 varRegion = buildVarRegion(region, self._axisTags) 62 idx = regionMap[key] = len(regionList.Region) 63 regionList.Region.append(varRegion) 64 regionIndices.append(idx) 65 66 # Check if we have one already... 67 key = tuple(regionIndices) 68 varDataIdx = self._varDataIndices.get(key) 69 if varDataIdx is not None: 70 self._outer = varDataIdx 71 self._data = self._store.VarData[varDataIdx] 72 self._cache = self._varDataCaches[key] 73 if len(self._data.Item) == 0xFFF: 74 # This is full. Need new one. 75 varDataIdx = None 76 77 if varDataIdx is None: 78 self._data = buildVarData(regionIndices, [], optimize=False) 79 self._outer = len(self._store.VarData) 80 self._store.VarData.append(self._data) 81 self._varDataIndices[key] = self._outer 82 if key not in self._varDataCaches: 83 self._varDataCaches[key] = {} 84 self._cache = self._varDataCaches[key] 85 86 87 def storeMasters(self, master_values): 88 deltas = self._model.getDeltas(master_values) 89 base = otRound(deltas.pop(0)) 90 return base, self.storeDeltas(deltas) 91 92 def storeDeltas(self, deltas): 93 # Pity that this exists here, since VarData_addItem 94 # does the same. But to look into our cache, it's 95 # good to adjust deltas here as well... 96 deltas = [otRound(d) for d in deltas] 97 if len(deltas) == len(self._supports) + 1: 98 deltas = tuple(deltas[1:]) 99 else: 100 assert len(deltas) == len(self._supports) 101 deltas = tuple(deltas) 102 103 varIdx = self._cache.get(deltas) 104 if varIdx is not None: 105 return varIdx 106 107 if not self._data: 108 self._add_VarData() 109 inner = len(self._data.Item) 110 if inner == 0xFFFF: 111 # Full array. Start new one. 112 self._add_VarData() 113 return self.storeDeltas(deltas) 114 self._data.addItem(deltas) 115 116 varIdx = (self._outer << 16) + inner 117 self._cache[deltas] = varIdx 118 return varIdx 119 120def VarData_addItem(self, deltas): 121 deltas = [otRound(d) for d in deltas] 122 123 countUs = self.VarRegionCount 124 countThem = len(deltas) 125 if countUs + 1 == countThem: 126 deltas = tuple(deltas[1:]) 127 else: 128 assert countUs == countThem, (countUs, countThem) 129 deltas = tuple(deltas) 130 self.Item.append(list(deltas)) 131 self.ItemCount = len(self.Item) 132 133ot.VarData.addItem = VarData_addItem 134 135def VarRegion_get_support(self, fvar_axes): 136 return {fvar_axes[i].axisTag: (reg.StartCoord,reg.PeakCoord,reg.EndCoord) 137 for i,reg in enumerate(self.VarRegionAxis)} 138 139ot.VarRegion.get_support = VarRegion_get_support 140 141class VarStoreInstancer(object): 142 143 def __init__(self, varstore, fvar_axes, location={}): 144 self.fvar_axes = fvar_axes 145 assert varstore is None or varstore.Format == 1 146 self._varData = varstore.VarData if varstore else [] 147 self._regions = varstore.VarRegionList.Region if varstore else [] 148 self.setLocation(location) 149 150 def setLocation(self, location): 151 self.location = dict(location) 152 self._clearCaches() 153 154 def _clearCaches(self): 155 self._scalars = {} 156 157 def _getScalar(self, regionIdx): 158 scalar = self._scalars.get(regionIdx) 159 if scalar is None: 160 support = self._regions[regionIdx].get_support(self.fvar_axes) 161 scalar = supportScalar(self.location, support) 162 self._scalars[regionIdx] = scalar 163 return scalar 164 165 @staticmethod 166 def interpolateFromDeltasAndScalars(deltas, scalars): 167 delta = 0. 168 for d,s in zip(deltas, scalars): 169 if not s: continue 170 delta += d * s 171 return delta 172 173 def __getitem__(self, varidx): 174 major, minor = varidx >> 16, varidx & 0xFFFF 175 varData = self._varData 176 scalars = [self._getScalar(ri) for ri in varData[major].VarRegionIndex] 177 deltas = varData[major].Item[minor] 178 return self.interpolateFromDeltasAndScalars(deltas, scalars) 179 180 def interpolateFromDeltas(self, varDataIndex, deltas): 181 varData = self._varData 182 scalars = [self._getScalar(ri) for ri in 183 varData[varDataIndex].VarRegionIndex] 184 return self.interpolateFromDeltasAndScalars(deltas, scalars) 185 186 187# 188# Optimizations 189# 190 191def VarStore_subset_varidxes(self, varIdxes, optimize=True): 192 193 # Sort out used varIdxes by major/minor. 194 used = {} 195 for varIdx in varIdxes: 196 major = varIdx >> 16 197 minor = varIdx & 0xFFFF 198 d = used.get(major) 199 if d is None: 200 d = used[major] = set() 201 d.add(minor) 202 del varIdxes 203 204 # 205 # Subset VarData 206 # 207 208 varData = self.VarData 209 newVarData = [] 210 varDataMap = {} 211 for major,data in enumerate(varData): 212 usedMinors = used.get(major) 213 if usedMinors is None: 214 continue 215 newMajor = len(newVarData) 216 newVarData.append(data) 217 218 items = data.Item 219 newItems = [] 220 for minor in sorted(usedMinors): 221 newMinor = len(newItems) 222 newItems.append(items[minor]) 223 varDataMap[(major<<16)+minor] = (newMajor<<16)+newMinor 224 225 data.Item = newItems 226 data.ItemCount = len(data.Item) 227 228 data.calculateNumShorts(optimize=optimize) 229 230 self.VarData = newVarData 231 self.VarDataCount = len(self.VarData) 232 233 self.prune_regions() 234 235 return varDataMap 236 237ot.VarStore.subset_varidxes = VarStore_subset_varidxes 238 239def VarStore_prune_regions(self): 240 """Remove unused VarRegions.""" 241 # 242 # Subset VarRegionList 243 # 244 245 # Collect. 246 usedRegions = set() 247 for data in self.VarData: 248 usedRegions.update(data.VarRegionIndex) 249 # Subset. 250 regionList = self.VarRegionList 251 regions = regionList.Region 252 newRegions = [] 253 regionMap = {} 254 for i in sorted(usedRegions): 255 regionMap[i] = len(newRegions) 256 newRegions.append(regions[i]) 257 regionList.Region = newRegions 258 regionList.RegionCount = len(regionList.Region) 259 # Map. 260 for data in self.VarData: 261 data.VarRegionIndex = [regionMap[i] for i in data.VarRegionIndex] 262 263ot.VarStore.prune_regions = VarStore_prune_regions 264 265 266def _visit(self, func): 267 """Recurse down from self, if type of an object is ot.Device, 268 call func() on it. Works on otData-style classes.""" 269 270 if type(self) == ot.Device: 271 func(self) 272 273 elif isinstance(self, list): 274 for that in self: 275 _visit(that, func) 276 277 elif hasattr(self, 'getConverters') and not hasattr(self, 'postRead'): 278 for conv in self.getConverters(): 279 that = getattr(self, conv.name, None) 280 if that is not None: 281 _visit(that, func) 282 283 elif isinstance(self, ot.ValueRecord): 284 for that in self.__dict__.values(): 285 _visit(that, func) 286 287def _Device_recordVarIdx(self, s): 288 """Add VarIdx in this Device table (if any) to the set s.""" 289 if self.DeltaFormat == 0x8000: 290 s.add((self.StartSize<<16)+self.EndSize) 291 292def Object_collect_device_varidxes(self, varidxes): 293 adder = partial(_Device_recordVarIdx, s=varidxes) 294 _visit(self, adder) 295 296ot.GDEF.collect_device_varidxes = Object_collect_device_varidxes 297ot.GPOS.collect_device_varidxes = Object_collect_device_varidxes 298 299def _Device_mapVarIdx(self, mapping, done): 300 """Map VarIdx in this Device table (if any) through mapping.""" 301 if id(self) in done: 302 return 303 done.add(id(self)) 304 if self.DeltaFormat == 0x8000: 305 varIdx = mapping[(self.StartSize<<16)+self.EndSize] 306 self.StartSize = varIdx >> 16 307 self.EndSize = varIdx & 0xFFFF 308 309def Object_remap_device_varidxes(self, varidxes_map): 310 mapper = partial(_Device_mapVarIdx, mapping=varidxes_map, done=set()) 311 _visit(self, mapper) 312 313ot.GDEF.remap_device_varidxes = Object_remap_device_varidxes 314ot.GPOS.remap_device_varidxes = Object_remap_device_varidxes 315 316 317class _Encoding(object): 318 319 def __init__(self, chars): 320 self.chars = chars 321 self.width = self._popcount(chars) 322 self.overhead = self._characteristic_overhead(chars) 323 self.items = set() 324 325 def append(self, row): 326 self.items.add(row) 327 328 def extend(self, lst): 329 self.items.update(lst) 330 331 def get_room(self): 332 """Maximum number of bytes that can be added to characteristic 333 while still being beneficial to merge it into another one.""" 334 count = len(self.items) 335 return max(0, (self.overhead - 1) // count - self.width) 336 room = property(get_room) 337 338 @property 339 def gain(self): 340 """Maximum possible byte gain from merging this into another 341 characteristic.""" 342 count = len(self.items) 343 return max(0, self.overhead - count * (self.width + 1)) 344 345 def sort_key(self): 346 return self.width, self.chars 347 348 def __len__(self): 349 return len(self.items) 350 351 def can_encode(self, chars): 352 return not (chars & ~self.chars) 353 354 def __sub__(self, other): 355 return self._popcount(self.chars & ~other.chars) 356 357 @staticmethod 358 def _popcount(n): 359 # Apparently this is the fastest native way to do it... 360 # https://stackoverflow.com/a/9831671 361 return bin(n).count('1') 362 363 @staticmethod 364 def _characteristic_overhead(chars): 365 """Returns overhead in bytes of encoding this characteristic 366 as a VarData.""" 367 c = 6 368 while chars: 369 if chars & 3: 370 c += 2 371 chars >>= 2 372 return c 373 374 375 def _find_yourself_best_new_encoding(self, done_by_width): 376 self.best_new_encoding = None 377 for new_width in range(self.width+1, self.width+self.room+1): 378 for new_encoding in done_by_width[new_width]: 379 if new_encoding.can_encode(self.chars): 380 break 381 else: 382 new_encoding = None 383 self.best_new_encoding = new_encoding 384 385 386class _EncodingDict(dict): 387 388 def __missing__(self, chars): 389 r = self[chars] = _Encoding(chars) 390 return r 391 392 def add_row(self, row): 393 chars = self._row_characteristics(row) 394 self[chars].append(row) 395 396 @staticmethod 397 def _row_characteristics(row): 398 """Returns encoding characteristics for a row.""" 399 chars = 0 400 i = 1 401 for v in row: 402 if v: 403 chars += i 404 if not (-128 <= v <= 127): 405 chars += i * 2 406 i <<= 2 407 return chars 408 409 410def VarStore_optimize(self): 411 """Optimize storage. Returns mapping from old VarIdxes to new ones.""" 412 413 # TODO 414 # Check that no two VarRegions are the same; if they are, fold them. 415 416 n = len(self.VarRegionList.Region) # Number of columns 417 zeroes = array('h', [0]*n) 418 419 front_mapping = {} # Map from old VarIdxes to full row tuples 420 421 encodings = _EncodingDict() 422 423 # Collect all items into a set of full rows (with lots of zeroes.) 424 for major,data in enumerate(self.VarData): 425 regionIndices = data.VarRegionIndex 426 427 for minor,item in enumerate(data.Item): 428 429 row = array('h', zeroes) 430 for regionIdx,v in zip(regionIndices, item): 431 row[regionIdx] += v 432 row = tuple(row) 433 434 encodings.add_row(row) 435 front_mapping[(major<<16)+minor] = row 436 437 # Separate encodings that have no gain (are decided) and those having 438 # possible gain (possibly to be merged into others.) 439 encodings = sorted(encodings.values(), key=_Encoding.__len__, reverse=True) 440 done_by_width = defaultdict(list) 441 todo = [] 442 for encoding in encodings: 443 if not encoding.gain: 444 done_by_width[encoding.width].append(encoding) 445 else: 446 todo.append(encoding) 447 448 # For each encoding that is possibly to be merged, find the best match 449 # in the decided encodings, and record that. 450 todo.sort(key=_Encoding.get_room) 451 for encoding in todo: 452 encoding._find_yourself_best_new_encoding(done_by_width) 453 454 # Walk through todo encodings, for each, see if merging it with 455 # another todo encoding gains more than each of them merging with 456 # their best decided encoding. If yes, merge them and add resulting 457 # encoding back to todo queue. If not, move the enconding to decided 458 # list. Repeat till done. 459 while todo: 460 encoding = todo.pop() 461 best_idx = None 462 best_gain = 0 463 for i,other_encoding in enumerate(todo): 464 combined_chars = other_encoding.chars | encoding.chars 465 combined_width = _Encoding._popcount(combined_chars) 466 combined_overhead = _Encoding._characteristic_overhead(combined_chars) 467 combined_gain = ( 468 + encoding.overhead 469 + other_encoding.overhead 470 - combined_overhead 471 - (combined_width - encoding.width) * len(encoding) 472 - (combined_width - other_encoding.width) * len(other_encoding) 473 ) 474 this_gain = 0 if encoding.best_new_encoding is None else ( 475 + encoding.overhead 476 - (encoding.best_new_encoding.width - encoding.width) * len(encoding) 477 ) 478 other_gain = 0 if other_encoding.best_new_encoding is None else ( 479 + other_encoding.overhead 480 - (other_encoding.best_new_encoding.width - other_encoding.width) * len(other_encoding) 481 ) 482 separate_gain = this_gain + other_gain 483 484 if combined_gain > separate_gain: 485 best_idx = i 486 best_gain = combined_gain - separate_gain 487 488 if best_idx is None: 489 # Encoding is decided as is 490 done_by_width[encoding.width].append(encoding) 491 else: 492 other_encoding = todo[best_idx] 493 combined_chars = other_encoding.chars | encoding.chars 494 combined_encoding = _Encoding(combined_chars) 495 combined_encoding.extend(encoding.items) 496 combined_encoding.extend(other_encoding.items) 497 combined_encoding._find_yourself_best_new_encoding(done_by_width) 498 del todo[best_idx] 499 todo.append(combined_encoding) 500 501 # Assemble final store. 502 back_mapping = {} # Mapping from full rows to new VarIdxes 503 encodings = sum(done_by_width.values(), []) 504 encodings.sort(key=_Encoding.sort_key) 505 self.VarData = [] 506 for major,encoding in enumerate(encodings): 507 data = ot.VarData() 508 self.VarData.append(data) 509 data.VarRegionIndex = range(n) 510 data.VarRegionCount = len(data.VarRegionIndex) 511 data.Item = sorted(encoding.items) 512 for minor,item in enumerate(data.Item): 513 back_mapping[item] = (major<<16)+minor 514 515 # Compile final mapping. 516 varidx_map = {} 517 for k,v in front_mapping.items(): 518 varidx_map[k] = back_mapping[v] 519 520 # Remove unused regions. 521 self.prune_regions() 522 523 # Recalculate things and go home. 524 self.VarRegionList.RegionCount = len(self.VarRegionList.Region) 525 self.VarDataCount = len(self.VarData) 526 for data in self.VarData: 527 data.ItemCount = len(data.Item) 528 data.optimize() 529 530 return varidx_map 531 532ot.VarStore.optimize = VarStore_optimize 533 534 535def main(args=None): 536 from argparse import ArgumentParser 537 from fontTools import configLogger 538 from fontTools.ttLib import TTFont 539 from fontTools.ttLib.tables.otBase import OTTableWriter 540 541 parser = ArgumentParser(prog='varLib.varStore') 542 parser.add_argument('fontfile') 543 parser.add_argument('outfile', nargs='?') 544 options = parser.parse_args(args) 545 546 # TODO: allow user to configure logging via command-line options 547 configLogger(level="INFO") 548 549 fontfile = options.fontfile 550 outfile = options.outfile 551 552 font = TTFont(fontfile) 553 gdef = font['GDEF'] 554 store = gdef.table.VarStore 555 556 writer = OTTableWriter() 557 store.compile(writer, font) 558 size = len(writer.getAllData()) 559 print("Before: %7d bytes" % size) 560 561 varidx_map = store.optimize() 562 563 gdef.table.remap_device_varidxes(varidx_map) 564 if 'GPOS' in font: 565 font['GPOS'].table.remap_device_varidxes(varidx_map) 566 567 writer = OTTableWriter() 568 store.compile(writer, font) 569 size = len(writer.getAllData()) 570 print("After: %7d bytes" % size) 571 572 if outfile is not None: 573 font.save(outfile) 574 575 576if __name__ == "__main__": 577 import sys 578 if len(sys.argv) > 1: 579 sys.exit(main()) 580 import doctest 581 sys.exit(doctest.testmod().failed) 582