Coverage for /usr/lib/python3/dist-packages/fontTools/varLib/varStore.py: 16%
431 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-14 15:55 +0200
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-14 15:55 +0200
1from fontTools.misc.roundTools import noRound, otRound
2from fontTools.misc.intTools import bit_count
3from fontTools.ttLib.tables import otTables as ot
4from fontTools.varLib.models import supportScalar
5from fontTools.varLib.builder import (
6 buildVarRegionList,
7 buildVarStore,
8 buildVarRegion,
9 buildVarData,
10)
11from functools import partial
12from collections import defaultdict
13from heapq import heappush, heappop
16NO_VARIATION_INDEX = ot.NO_VARIATION_INDEX
17ot.VarStore.NO_VARIATION_INDEX = NO_VARIATION_INDEX
20def _getLocationKey(loc):
21 return tuple(sorted(loc.items(), key=lambda kv: kv[0]))
24class OnlineVarStoreBuilder(object):
25 def __init__(self, axisTags):
26 self._axisTags = axisTags
27 self._regionMap = {}
28 self._regionList = buildVarRegionList([], axisTags)
29 self._store = buildVarStore(self._regionList, [])
30 self._data = None
31 self._model = None
32 self._supports = None
33 self._varDataIndices = {}
34 self._varDataCaches = {}
35 self._cache = {}
37 def setModel(self, model):
38 self.setSupports(model.supports)
39 self._model = model
41 def setSupports(self, supports):
42 self._model = None
43 self._supports = list(supports)
44 if not self._supports[0]:
45 del self._supports[0] # Drop base master support
46 self._cache = {}
47 self._data = None
49 def finish(self, optimize=True):
50 self._regionList.RegionCount = len(self._regionList.Region)
51 self._store.VarDataCount = len(self._store.VarData)
52 for data in self._store.VarData:
53 data.ItemCount = len(data.Item)
54 data.calculateNumShorts(optimize=optimize)
55 return self._store
57 def _add_VarData(self):
58 regionMap = self._regionMap
59 regionList = self._regionList
61 regions = self._supports
62 regionIndices = []
63 for region in regions:
64 key = _getLocationKey(region)
65 idx = regionMap.get(key)
66 if idx is None:
67 varRegion = buildVarRegion(region, self._axisTags)
68 idx = regionMap[key] = len(regionList.Region)
69 regionList.Region.append(varRegion)
70 regionIndices.append(idx)
72 # Check if we have one already...
73 key = tuple(regionIndices)
74 varDataIdx = self._varDataIndices.get(key)
75 if varDataIdx is not None:
76 self._outer = varDataIdx
77 self._data = self._store.VarData[varDataIdx]
78 self._cache = self._varDataCaches[key]
79 if len(self._data.Item) == 0xFFFF:
80 # This is full. Need new one.
81 varDataIdx = None
83 if varDataIdx is None:
84 self._data = buildVarData(regionIndices, [], optimize=False)
85 self._outer = len(self._store.VarData)
86 self._store.VarData.append(self._data)
87 self._varDataIndices[key] = self._outer
88 if key not in self._varDataCaches:
89 self._varDataCaches[key] = {}
90 self._cache = self._varDataCaches[key]
92 def storeMasters(self, master_values, *, round=round):
93 deltas = self._model.getDeltas(master_values, round=round)
94 base = deltas.pop(0)
95 return base, self.storeDeltas(deltas, round=noRound)
97 def storeDeltas(self, deltas, *, round=round):
98 deltas = [round(d) for d in deltas]
99 if len(deltas) == len(self._supports) + 1:
100 deltas = tuple(deltas[1:])
101 else:
102 assert len(deltas) == len(self._supports)
103 deltas = tuple(deltas)
105 varIdx = self._cache.get(deltas)
106 if varIdx is not None:
107 return varIdx
109 if not self._data:
110 self._add_VarData()
111 inner = len(self._data.Item)
112 if inner == 0xFFFF:
113 # Full array. Start new one.
114 self._add_VarData()
115 return self.storeDeltas(deltas)
116 self._data.addItem(deltas, round=noRound)
118 varIdx = (self._outer << 16) + inner
119 self._cache[deltas] = varIdx
120 return varIdx
123def VarData_addItem(self, deltas, *, round=round):
124 deltas = [round(d) for d in deltas]
126 countUs = self.VarRegionCount
127 countThem = len(deltas)
128 if countUs + 1 == countThem:
129 deltas = list(deltas[1:])
130 else:
131 assert countUs == countThem, (countUs, countThem)
132 deltas = list(deltas)
133 self.Item.append(deltas)
134 self.ItemCount = len(self.Item)
137ot.VarData.addItem = VarData_addItem
140def VarRegion_get_support(self, fvar_axes):
141 return {
142 fvar_axes[i].axisTag: (reg.StartCoord, reg.PeakCoord, reg.EndCoord)
143 for i, reg in enumerate(self.VarRegionAxis)
144 if reg.PeakCoord != 0
145 }
148ot.VarRegion.get_support = VarRegion_get_support
151def VarStore___bool__(self):
152 return bool(self.VarData)
155ot.VarStore.__bool__ = VarStore___bool__
158class VarStoreInstancer(object):
159 def __init__(self, varstore, fvar_axes, location={}):
160 self.fvar_axes = fvar_axes
161 assert varstore is None or varstore.Format == 1
162 self._varData = varstore.VarData if varstore else []
163 self._regions = varstore.VarRegionList.Region if varstore else []
164 self.setLocation(location)
166 def setLocation(self, location):
167 self.location = dict(location)
168 self._clearCaches()
170 def _clearCaches(self):
171 self._scalars = {}
173 def _getScalar(self, regionIdx):
174 scalar = self._scalars.get(regionIdx)
175 if scalar is None:
176 support = self._regions[regionIdx].get_support(self.fvar_axes)
177 scalar = supportScalar(self.location, support)
178 self._scalars[regionIdx] = scalar
179 return scalar
181 @staticmethod
182 def interpolateFromDeltasAndScalars(deltas, scalars):
183 delta = 0.0
184 for d, s in zip(deltas, scalars):
185 if not s:
186 continue
187 delta += d * s
188 return delta
190 def __getitem__(self, varidx):
191 major, minor = varidx >> 16, varidx & 0xFFFF
192 if varidx == NO_VARIATION_INDEX:
193 return 0.0
194 varData = self._varData
195 scalars = [self._getScalar(ri) for ri in varData[major].VarRegionIndex]
196 deltas = varData[major].Item[minor]
197 return self.interpolateFromDeltasAndScalars(deltas, scalars)
199 def interpolateFromDeltas(self, varDataIndex, deltas):
200 varData = self._varData
201 scalars = [self._getScalar(ri) for ri in varData[varDataIndex].VarRegionIndex]
202 return self.interpolateFromDeltasAndScalars(deltas, scalars)
205#
206# Optimizations
207#
208# retainFirstMap - If true, major 0 mappings are retained. Deltas for unused indices are zeroed
209# advIdxes - Set of major 0 indices for advance deltas to be listed first. Other major 0 indices follow.
212def VarStore_subset_varidxes(
213 self, varIdxes, optimize=True, retainFirstMap=False, advIdxes=set()
214):
215 # Sort out used varIdxes by major/minor.
216 used = {}
217 for varIdx in varIdxes:
218 if varIdx == NO_VARIATION_INDEX:
219 continue
220 major = varIdx >> 16
221 minor = varIdx & 0xFFFF
222 d = used.get(major)
223 if d is None:
224 d = used[major] = set()
225 d.add(minor)
226 del varIdxes
228 #
229 # Subset VarData
230 #
232 varData = self.VarData
233 newVarData = []
234 varDataMap = {NO_VARIATION_INDEX: NO_VARIATION_INDEX}
235 for major, data in enumerate(varData):
236 usedMinors = used.get(major)
237 if usedMinors is None:
238 continue
239 newMajor = len(newVarData)
240 newVarData.append(data)
242 items = data.Item
243 newItems = []
244 if major == 0 and retainFirstMap:
245 for minor in range(len(items)):
246 newItems.append(
247 items[minor] if minor in usedMinors else [0] * len(items[minor])
248 )
249 varDataMap[minor] = minor
250 else:
251 if major == 0:
252 minors = sorted(advIdxes) + sorted(usedMinors - advIdxes)
253 else:
254 minors = sorted(usedMinors)
255 for minor in minors:
256 newMinor = len(newItems)
257 newItems.append(items[minor])
258 varDataMap[(major << 16) + minor] = (newMajor << 16) + newMinor
260 data.Item = newItems
261 data.ItemCount = len(data.Item)
263 data.calculateNumShorts(optimize=optimize)
265 self.VarData = newVarData
266 self.VarDataCount = len(self.VarData)
268 self.prune_regions()
270 return varDataMap
273ot.VarStore.subset_varidxes = VarStore_subset_varidxes
276def VarStore_prune_regions(self):
277 """Remove unused VarRegions."""
278 #
279 # Subset VarRegionList
280 #
282 # Collect.
283 usedRegions = set()
284 for data in self.VarData:
285 usedRegions.update(data.VarRegionIndex)
286 # Subset.
287 regionList = self.VarRegionList
288 regions = regionList.Region
289 newRegions = []
290 regionMap = {}
291 for i in sorted(usedRegions):
292 regionMap[i] = len(newRegions)
293 newRegions.append(regions[i])
294 regionList.Region = newRegions
295 regionList.RegionCount = len(regionList.Region)
296 # Map.
297 for data in self.VarData:
298 data.VarRegionIndex = [regionMap[i] for i in data.VarRegionIndex]
301ot.VarStore.prune_regions = VarStore_prune_regions
304def _visit(self, func):
305 """Recurse down from self, if type of an object is ot.Device,
306 call func() on it. Works on otData-style classes."""
308 if type(self) == ot.Device:
309 func(self)
311 elif isinstance(self, list):
312 for that in self:
313 _visit(that, func)
315 elif hasattr(self, "getConverters") and not hasattr(self, "postRead"):
316 for conv in self.getConverters():
317 that = getattr(self, conv.name, None)
318 if that is not None:
319 _visit(that, func)
321 elif isinstance(self, ot.ValueRecord):
322 for that in self.__dict__.values():
323 _visit(that, func)
326def _Device_recordVarIdx(self, s):
327 """Add VarIdx in this Device table (if any) to the set s."""
328 if self.DeltaFormat == 0x8000:
329 s.add((self.StartSize << 16) + self.EndSize)
332def Object_collect_device_varidxes(self, varidxes):
333 adder = partial(_Device_recordVarIdx, s=varidxes)
334 _visit(self, adder)
337ot.GDEF.collect_device_varidxes = Object_collect_device_varidxes
338ot.GPOS.collect_device_varidxes = Object_collect_device_varidxes
341def _Device_mapVarIdx(self, mapping, done):
342 """Map VarIdx in this Device table (if any) through mapping."""
343 if id(self) in done:
344 return
345 done.add(id(self))
346 if self.DeltaFormat == 0x8000:
347 varIdx = mapping[(self.StartSize << 16) + self.EndSize]
348 self.StartSize = varIdx >> 16
349 self.EndSize = varIdx & 0xFFFF
352def Object_remap_device_varidxes(self, varidxes_map):
353 mapper = partial(_Device_mapVarIdx, mapping=varidxes_map, done=set())
354 _visit(self, mapper)
357ot.GDEF.remap_device_varidxes = Object_remap_device_varidxes
358ot.GPOS.remap_device_varidxes = Object_remap_device_varidxes
361class _Encoding(object):
362 def __init__(self, chars):
363 self.chars = chars
364 self.width = bit_count(chars)
365 self.columns = self._columns(chars)
366 self.overhead = self._characteristic_overhead(self.columns)
367 self.items = set()
369 def append(self, row):
370 self.items.add(row)
372 def extend(self, lst):
373 self.items.update(lst)
375 def get_room(self):
376 """Maximum number of bytes that can be added to characteristic
377 while still being beneficial to merge it into another one."""
378 count = len(self.items)
379 return max(0, (self.overhead - 1) // count - self.width)
381 room = property(get_room)
383 def get_gain(self):
384 """Maximum possible byte gain from merging this into another
385 characteristic."""
386 count = len(self.items)
387 return max(0, self.overhead - count)
389 gain = property(get_gain)
391 def gain_sort_key(self):
392 return self.gain, self.chars
394 def width_sort_key(self):
395 return self.width, self.chars
397 @staticmethod
398 def _characteristic_overhead(columns):
399 """Returns overhead in bytes of encoding this characteristic
400 as a VarData."""
401 c = 4 + 6 # 4 bytes for LOffset, 6 bytes for VarData header
402 c += bit_count(columns) * 2
403 return c
405 @staticmethod
406 def _columns(chars):
407 cols = 0
408 i = 1
409 while chars:
410 if chars & 0b1111:
411 cols |= i
412 chars >>= 4
413 i <<= 1
414 return cols
416 def gain_from_merging(self, other_encoding):
417 combined_chars = other_encoding.chars | self.chars
418 combined_width = bit_count(combined_chars)
419 combined_columns = self.columns | other_encoding.columns
420 combined_overhead = _Encoding._characteristic_overhead(combined_columns)
421 combined_gain = (
422 +self.overhead
423 + other_encoding.overhead
424 - combined_overhead
425 - (combined_width - self.width) * len(self.items)
426 - (combined_width - other_encoding.width) * len(other_encoding.items)
427 )
428 return combined_gain
431class _EncodingDict(dict):
432 def __missing__(self, chars):
433 r = self[chars] = _Encoding(chars)
434 return r
436 def add_row(self, row):
437 chars = self._row_characteristics(row)
438 self[chars].append(row)
440 @staticmethod
441 def _row_characteristics(row):
442 """Returns encoding characteristics for a row."""
443 longWords = False
445 chars = 0
446 i = 1
447 for v in row:
448 if v:
449 chars += i
450 if not (-128 <= v <= 127):
451 chars += i * 0b0010
452 if not (-32768 <= v <= 32767):
453 longWords = True
454 break
455 i <<= 4
457 if longWords:
458 # Redo; only allow 2byte/4byte encoding
459 chars = 0
460 i = 1
461 for v in row:
462 if v:
463 chars += i * 0b0011
464 if not (-32768 <= v <= 32767):
465 chars += i * 0b1100
466 i <<= 4
468 return chars
471def VarStore_optimize(self, use_NO_VARIATION_INDEX=True, quantization=1):
472 """Optimize storage. Returns mapping from old VarIdxes to new ones."""
474 # Overview:
475 #
476 # For each VarData row, we first extend it with zeroes to have
477 # one column per region in VarRegionList. We then group the
478 # rows into _Encoding objects, by their "characteristic" bitmap.
479 # The characteristic bitmap is a binary number representing how
480 # many bytes each column of the data takes up to encode. Each
481 # column is encoded in four bits. For example, if a column has
482 # only values in the range -128..127, it would only have a single
483 # bit set in the characteristic bitmap for that column. If it has
484 # values in the range -32768..32767, it would have two bits set.
485 # The number of ones in the characteristic bitmap is the "width"
486 # of the encoding.
487 #
488 # Each encoding as such has a number of "active" (ie. non-zero)
489 # columns. The overhead of encoding the characteristic bitmap
490 # is 10 bytes, plus 2 bytes per active column.
491 #
492 # When an encoding is merged into another one, if the characteristic
493 # of the old encoding is a subset of the new one, then the overhead
494 # of the old encoding is completely eliminated. However, each row
495 # now would require more bytes to encode, to the tune of one byte
496 # per characteristic bit that is active in the new encoding but not
497 # in the old one. The number of bits that can be added to an encoding
498 # while still beneficial to merge it into another encoding is called
499 # the "room" for that encoding.
500 #
501 # The "gain" of an encodings is the maximum number of bytes we can
502 # save by merging it into another encoding. The "gain" of merging
503 # two encodings is how many bytes we save by doing so.
504 #
505 # High-level algorithm:
506 #
507 # - Each encoding has a minimal way to encode it. However, because
508 # of the overhead of encoding the characteristic bitmap, it may
509 # be beneficial to merge two encodings together, if there is
510 # gain in doing so. As such, we need to search for the best
511 # such successive merges.
512 #
513 # Algorithm:
514 #
515 # - Put all encodings into a "todo" list.
516 #
517 # - Sort todo list by decreasing gain (for stability).
518 #
519 # - Make a priority-queue of the gain from combining each two
520 # encodings in the todo list. The priority queue is sorted by
521 # decreasing gain. Only positive gains are included.
522 #
523 # - While priority queue is not empty:
524 # - Pop the first item from the priority queue,
525 # - Merge the two encodings it represents,
526 # - Remove the two encodings from the todo list,
527 # - Insert positive gains from combining the new encoding with
528 # all existing todo list items into the priority queue,
529 # - If a todo list item with the same characteristic bitmap as
530 # the new encoding exists, remove it from the todo list and
531 # merge it into the new encoding.
532 # - Insert the new encoding into the todo list,
533 #
534 # - Encode all remaining items in the todo list.
535 #
536 # The output is then sorted for stability, in the following way:
537 # - The VarRegionList of the input is kept intact.
538 # - All encodings are sorted before the main algorithm, by
539 # gain_key_sort(), which is a tuple of the following items:
540 # * The gain of the encoding.
541 # * The characteristic bitmap of the encoding, with higher-numbered
542 # columns compared first.
543 # - The VarData is sorted by width_sort_key(), which is a tuple
544 # of the following items:
545 # * The "width" of the encoding.
546 # * The characteristic bitmap of the encoding, with higher-numbered
547 # columns compared first.
548 # - Within each VarData, the items are sorted as vectors of numbers.
549 #
550 # Finally, each VarData is optimized to remove the empty columns and
551 # reorder columns as needed.
553 # TODO
554 # Check that no two VarRegions are the same; if they are, fold them.
556 n = len(self.VarRegionList.Region) # Number of columns
557 zeroes = [0] * n
559 front_mapping = {} # Map from old VarIdxes to full row tuples
561 encodings = _EncodingDict()
563 # Collect all items into a set of full rows (with lots of zeroes.)
564 for major, data in enumerate(self.VarData):
565 regionIndices = data.VarRegionIndex
567 for minor, item in enumerate(data.Item):
568 row = list(zeroes)
570 if quantization == 1:
571 for regionIdx, v in zip(regionIndices, item):
572 row[regionIdx] += v
573 else:
574 for regionIdx, v in zip(regionIndices, item):
575 row[regionIdx] += (
576 round(v / quantization) * quantization
577 ) # TODO https://github.com/fonttools/fonttools/pull/3126#discussion_r1205439785
579 row = tuple(row)
581 if use_NO_VARIATION_INDEX and not any(row):
582 front_mapping[(major << 16) + minor] = None
583 continue
585 encodings.add_row(row)
586 front_mapping[(major << 16) + minor] = row
588 # Prepare for the main algorithm.
589 todo = sorted(encodings.values(), key=_Encoding.gain_sort_key)
590 del encodings
592 # Repeatedly pick two best encodings to combine, and combine them.
594 heap = []
595 for i, encoding in enumerate(todo):
596 for j in range(i + 1, len(todo)):
597 other_encoding = todo[j]
598 combining_gain = encoding.gain_from_merging(other_encoding)
599 if combining_gain > 0:
600 heappush(heap, (-combining_gain, i, j))
602 while heap:
603 _, i, j = heappop(heap)
604 if todo[i] is None or todo[j] is None:
605 continue
607 encoding, other_encoding = todo[i], todo[j]
608 todo[i], todo[j] = None, None
610 # Combine the two encodings
611 combined_chars = other_encoding.chars | encoding.chars
612 combined_encoding = _Encoding(combined_chars)
613 combined_encoding.extend(encoding.items)
614 combined_encoding.extend(other_encoding.items)
616 for k, enc in enumerate(todo):
617 if enc is None:
618 continue
620 # In the unlikely event that the same encoding exists already,
621 # combine it.
622 if enc.chars == combined_chars:
623 combined_encoding.extend(enc.items)
624 todo[k] = None
625 continue
627 combining_gain = combined_encoding.gain_from_merging(enc)
628 if combining_gain > 0:
629 heappush(heap, (-combining_gain, k, len(todo)))
631 todo.append(combined_encoding)
633 encodings = [encoding for encoding in todo if encoding is not None]
635 # Assemble final store.
636 back_mapping = {} # Mapping from full rows to new VarIdxes
637 encodings.sort(key=_Encoding.width_sort_key)
638 self.VarData = []
639 for encoding in encodings:
640 items = sorted(encoding.items)
642 while items:
643 major = len(self.VarData)
644 data = ot.VarData()
645 self.VarData.append(data)
646 data.VarRegionIndex = range(n)
647 data.VarRegionCount = len(data.VarRegionIndex)
649 # Each major can only encode up to 0xFFFF entries.
650 data.Item, items = items[:0xFFFF], items[0xFFFF:]
652 for minor, item in enumerate(data.Item):
653 back_mapping[item] = (major << 16) + minor
655 # Compile final mapping.
656 varidx_map = {NO_VARIATION_INDEX: NO_VARIATION_INDEX}
657 for k, v in front_mapping.items():
658 varidx_map[k] = back_mapping[v] if v is not None else NO_VARIATION_INDEX
660 # Recalculate things and go home.
661 self.VarRegionList.RegionCount = len(self.VarRegionList.Region)
662 self.VarDataCount = len(self.VarData)
663 for data in self.VarData:
664 data.ItemCount = len(data.Item)
665 data.optimize()
667 # Remove unused regions.
668 self.prune_regions()
670 return varidx_map
673ot.VarStore.optimize = VarStore_optimize
676def main(args=None):
677 """Optimize a font's GDEF variation store"""
678 from argparse import ArgumentParser
679 from fontTools import configLogger
680 from fontTools.ttLib import TTFont
681 from fontTools.ttLib.tables.otBase import OTTableWriter
683 parser = ArgumentParser(prog="varLib.varStore", description=main.__doc__)
684 parser.add_argument("--quantization", type=int, default=1)
685 parser.add_argument("fontfile")
686 parser.add_argument("outfile", nargs="?")
687 options = parser.parse_args(args)
689 # TODO: allow user to configure logging via command-line options
690 configLogger(level="INFO")
692 quantization = options.quantization
693 fontfile = options.fontfile
694 outfile = options.outfile
696 font = TTFont(fontfile)
697 gdef = font["GDEF"]
698 store = gdef.table.VarStore
700 writer = OTTableWriter()
701 store.compile(writer, font)
702 size = len(writer.getAllData())
703 print("Before: %7d bytes" % size)
705 varidx_map = store.optimize(quantization=quantization)
707 writer = OTTableWriter()
708 store.compile(writer, font)
709 size = len(writer.getAllData())
710 print("After: %7d bytes" % size)
712 if outfile is not None:
713 gdef.table.remap_device_varidxes(varidx_map)
714 if "GPOS" in font:
715 font["GPOS"].table.remap_device_varidxes(varidx_map)
717 font.save(outfile)
720if __name__ == "__main__":
721 import sys
723 if len(sys.argv) > 1:
724 sys.exit(main())
725 import doctest
727 sys.exit(doctest.testmod().failed)