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