width.py 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. # -*- coding: utf-8 -*-
  2. """T2CharString glyph width optimizer.
  3. CFF glyphs whose width equals the CFF Private dictionary's ``defaultWidthX``
  4. value do not need to specify their width in their charstring, saving bytes.
  5. This module determines the optimum ``defaultWidthX`` and ``nominalWidthX``
  6. values for a font, when provided with a list of glyph widths."""
  7. from fontTools.ttLib import TTFont
  8. from collections import defaultdict
  9. from operator import add
  10. from functools import reduce
  11. __all__ = ["optimizeWidths", "main"]
  12. class missingdict(dict):
  13. def __init__(self, missing_func):
  14. self.missing_func = missing_func
  15. def __missing__(self, v):
  16. return self.missing_func(v)
  17. def cumSum(f, op=add, start=0, decreasing=False):
  18. keys = sorted(f.keys())
  19. minx, maxx = keys[0], keys[-1]
  20. total = reduce(op, f.values(), start)
  21. if decreasing:
  22. missing = lambda x: start if x > maxx else total
  23. domain = range(maxx, minx - 1, -1)
  24. else:
  25. missing = lambda x: start if x < minx else total
  26. domain = range(minx, maxx + 1)
  27. out = missingdict(missing)
  28. v = start
  29. for x in domain:
  30. v = op(v, f[x])
  31. out[x] = v
  32. return out
  33. def byteCost(widths, default, nominal):
  34. if not hasattr(widths, "items"):
  35. d = defaultdict(int)
  36. for w in widths:
  37. d[w] += 1
  38. widths = d
  39. cost = 0
  40. for w, freq in widths.items():
  41. if w == default:
  42. continue
  43. diff = abs(w - nominal)
  44. if diff <= 107:
  45. cost += freq
  46. elif diff <= 1131:
  47. cost += freq * 2
  48. else:
  49. cost += freq * 5
  50. return cost
  51. def optimizeWidthsBruteforce(widths):
  52. """Bruteforce version. Veeeeeeeeeeeeeeeeery slow. Only works for smallests of fonts."""
  53. d = defaultdict(int)
  54. for w in widths:
  55. d[w] += 1
  56. # Maximum number of bytes using default can possibly save
  57. maxDefaultAdvantage = 5 * max(d.values())
  58. minw, maxw = min(widths), max(widths)
  59. domain = list(range(minw, maxw + 1))
  60. bestCostWithoutDefault = min(byteCost(widths, None, nominal) for nominal in domain)
  61. bestCost = len(widths) * 5 + 1
  62. for nominal in domain:
  63. if byteCost(widths, None, nominal) > bestCost + maxDefaultAdvantage:
  64. continue
  65. for default in domain:
  66. cost = byteCost(widths, default, nominal)
  67. if cost < bestCost:
  68. bestCost = cost
  69. bestDefault = default
  70. bestNominal = nominal
  71. return bestDefault, bestNominal
  72. def optimizeWidths(widths):
  73. """Given a list of glyph widths, or dictionary mapping glyph width to number of
  74. glyphs having that, returns a tuple of best CFF default and nominal glyph widths.
  75. This algorithm is linear in UPEM+numGlyphs."""
  76. if not hasattr(widths, "items"):
  77. d = defaultdict(int)
  78. for w in widths:
  79. d[w] += 1
  80. widths = d
  81. keys = sorted(widths.keys())
  82. minw, maxw = keys[0], keys[-1]
  83. domain = list(range(minw, maxw + 1))
  84. # Cumulative sum/max forward/backward.
  85. cumFrqU = cumSum(widths, op=add)
  86. cumMaxU = cumSum(widths, op=max)
  87. cumFrqD = cumSum(widths, op=add, decreasing=True)
  88. cumMaxD = cumSum(widths, op=max, decreasing=True)
  89. # Cost per nominal choice, without default consideration.
  90. nomnCostU = missingdict(
  91. lambda x: cumFrqU[x] + cumFrqU[x - 108] + cumFrqU[x - 1132] * 3
  92. )
  93. nomnCostD = missingdict(
  94. lambda x: cumFrqD[x] + cumFrqD[x + 108] + cumFrqD[x + 1132] * 3
  95. )
  96. nomnCost = missingdict(lambda x: nomnCostU[x] + nomnCostD[x] - widths[x])
  97. # Cost-saving per nominal choice, by best default choice.
  98. dfltCostU = missingdict(
  99. lambda x: max(cumMaxU[x], cumMaxU[x - 108] * 2, cumMaxU[x - 1132] * 5)
  100. )
  101. dfltCostD = missingdict(
  102. lambda x: max(cumMaxD[x], cumMaxD[x + 108] * 2, cumMaxD[x + 1132] * 5)
  103. )
  104. dfltCost = missingdict(lambda x: max(dfltCostU[x], dfltCostD[x]))
  105. # Combined cost per nominal choice.
  106. bestCost = missingdict(lambda x: nomnCost[x] - dfltCost[x])
  107. # Best nominal.
  108. nominal = min(domain, key=lambda x: bestCost[x])
  109. # Work back the best default.
  110. bestC = bestCost[nominal]
  111. dfltC = nomnCost[nominal] - bestCost[nominal]
  112. ends = []
  113. if dfltC == dfltCostU[nominal]:
  114. starts = [nominal, nominal - 108, nominal - 1132]
  115. for start in starts:
  116. while cumMaxU[start] and cumMaxU[start] == cumMaxU[start - 1]:
  117. start -= 1
  118. ends.append(start)
  119. else:
  120. starts = [nominal, nominal + 108, nominal + 1132]
  121. for start in starts:
  122. while cumMaxD[start] and cumMaxD[start] == cumMaxD[start + 1]:
  123. start += 1
  124. ends.append(start)
  125. default = min(ends, key=lambda default: byteCost(widths, default, nominal))
  126. return default, nominal
  127. def main(args=None):
  128. """Calculate optimum defaultWidthX/nominalWidthX values"""
  129. import argparse
  130. parser = argparse.ArgumentParser(
  131. "fonttools cffLib.width",
  132. description=main.__doc__,
  133. )
  134. parser.add_argument(
  135. "inputs", metavar="FILE", type=str, nargs="+", help="Input TTF files"
  136. )
  137. parser.add_argument(
  138. "-b",
  139. "--brute-force",
  140. dest="brute",
  141. action="store_true",
  142. help="Use brute-force approach (VERY slow)",
  143. )
  144. args = parser.parse_args(args)
  145. for fontfile in args.inputs:
  146. font = TTFont(fontfile)
  147. hmtx = font["hmtx"]
  148. widths = [m[0] for m in hmtx.metrics.values()]
  149. if args.brute:
  150. default, nominal = optimizeWidthsBruteforce(widths)
  151. else:
  152. default, nominal = optimizeWidths(widths)
  153. print(
  154. "glyphs=%d default=%d nominal=%d byteCost=%d"
  155. % (len(widths), default, nominal, byteCost(widths, default, nominal))
  156. )
  157. if __name__ == "__main__":
  158. import sys
  159. if len(sys.argv) == 1:
  160. import doctest
  161. sys.exit(doctest.testmod().failed)
  162. main()