Haibo Huang | 8b3c57b | 2018-07-03 17:43:11 -0700 | [diff] [blame] | 1 | # -*- coding: utf-8 -*- |
| 2 | |
Haibo Huang | add91cd | 2020-05-15 14:50:34 -0700 | [diff] [blame] | 3 | """T2CharString glyph width optimizer. |
| 4 | |
| 5 | CFF glyphs whose width equals the CFF Private dictionary's ``defaultWidthX`` |
| 6 | value do not need to specify their width in their charstring, saving bytes. |
| 7 | This module determines the optimum ``defaultWidthX`` and ``nominalWidthX`` |
| 8 | values for a font, when provided with a list of glyph widths.""" |
Haibo Huang | 8b3c57b | 2018-07-03 17:43:11 -0700 | [diff] [blame] | 9 | |
Elliott Hughes | 6cf80b8 | 2021-04-01 15:12:06 -0700 | [diff] [blame] | 10 | from fontTools.ttLib import TTFont |
Haibo Huang | 8b3c57b | 2018-07-03 17:43:11 -0700 | [diff] [blame] | 11 | from collections import defaultdict |
| 12 | from operator import add |
Elliott Hughes | 6cf80b8 | 2021-04-01 15:12:06 -0700 | [diff] [blame] | 13 | from functools import reduce |
Haibo Huang | 8b3c57b | 2018-07-03 17:43:11 -0700 | [diff] [blame] | 14 | |
| 15 | |
| 16 | class 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 | |
| 22 | def 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 | |
| 45 | def 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 | |
| 66 | def 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 | |
| 95 | def 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 Hughes | ae8de17 | 2022-08-24 18:50:57 +0000 | [diff] [blame] | 138 | starts = [nominal, nominal-108, nominal-1132] |
Haibo Huang | 8b3c57b | 2018-07-03 17:43:11 -0700 | [diff] [blame] | 139 | for start in starts: |
| 140 | while cumMaxU[start] and cumMaxU[start] == cumMaxU[start-1]: |
| 141 | start -= 1 |
| 142 | ends.append(start) |
| 143 | else: |
Elliott Hughes | ae8de17 | 2022-08-24 18:50:57 +0000 | [diff] [blame] | 144 | starts = [nominal, nominal+108, nominal+1132] |
Haibo Huang | 8b3c57b | 2018-07-03 17:43:11 -0700 | [diff] [blame] | 145 | 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 Huang | add91cd | 2020-05-15 14:50:34 -0700 | [diff] [blame] | 153 | def 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 Huang | 8b3c57b | 2018-07-03 17:43:11 -0700 | [diff] [blame] | 177 | |
| 178 | if __name__ == '__main__': |
| 179 | import sys |
| 180 | if len(sys.argv) == 1: |
| 181 | import doctest |
| 182 | sys.exit(doctest.testmod().failed) |
Haibo Huang | add91cd | 2020-05-15 14:50:34 -0700 | [diff] [blame] | 183 | main() |