width.py 5.9 KB

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