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