You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
184 lines
5.1 KiB
184 lines
5.1 KiB
# -*- coding: utf-8 -*-
|
|
|
|
"""T2CharString glyph width optimizer.
|
|
|
|
CFF glyphs whose width equals the CFF Private dictionary's ``defaultWidthX``
|
|
value do not need to specify their width in their charstring, saving bytes.
|
|
This module determines the optimum ``defaultWidthX`` and ``nominalWidthX``
|
|
values for a font, when provided with a list of glyph widths."""
|
|
|
|
from fontTools.ttLib import TTFont
|
|
from collections import defaultdict
|
|
from operator import add
|
|
from functools import reduce
|
|
|
|
|
|
class missingdict(dict):
|
|
def __init__(self, missing_func):
|
|
self.missing_func = missing_func
|
|
def __missing__(self, v):
|
|
return self.missing_func(v)
|
|
|
|
def cumSum(f, op=add, start=0, decreasing=False):
|
|
|
|
keys = sorted(f.keys())
|
|
minx, maxx = keys[0], keys[-1]
|
|
|
|
total = reduce(op, f.values(), start)
|
|
|
|
if decreasing:
|
|
missing = lambda x: start if x > maxx else total
|
|
domain = range(maxx, minx - 1, -1)
|
|
else:
|
|
missing = lambda x: start if x < minx else total
|
|
domain = range(minx, maxx + 1)
|
|
|
|
out = missingdict(missing)
|
|
|
|
v = start
|
|
for x in domain:
|
|
v = op(v, f[x])
|
|
out[x] = v
|
|
|
|
return out
|
|
|
|
def byteCost(widths, default, nominal):
|
|
|
|
if not hasattr(widths, 'items'):
|
|
d = defaultdict(int)
|
|
for w in widths:
|
|
d[w] += 1
|
|
widths = d
|
|
|
|
cost = 0
|
|
for w,freq in widths.items():
|
|
if w == default: continue
|
|
diff = abs(w - nominal)
|
|
if diff <= 107:
|
|
cost += freq
|
|
elif diff <= 1131:
|
|
cost += freq * 2
|
|
else:
|
|
cost += freq * 5
|
|
return cost
|
|
|
|
|
|
def optimizeWidthsBruteforce(widths):
|
|
"""Bruteforce version. Veeeeeeeeeeeeeeeeery slow. Only works for smallests of fonts."""
|
|
|
|
d = defaultdict(int)
|
|
for w in widths:
|
|
d[w] += 1
|
|
|
|
# Maximum number of bytes using default can possibly save
|
|
maxDefaultAdvantage = 5 * max(d.values())
|
|
|
|
minw, maxw = min(widths), max(widths)
|
|
domain = list(range(minw, maxw+1))
|
|
|
|
bestCostWithoutDefault = min(byteCost(widths, None, nominal) for nominal in domain)
|
|
|
|
bestCost = len(widths) * 5 + 1
|
|
for nominal in domain:
|
|
if byteCost(widths, None, nominal) > bestCost + maxDefaultAdvantage:
|
|
continue
|
|
for default in domain:
|
|
cost = byteCost(widths, default, nominal)
|
|
if cost < bestCost:
|
|
bestCost = cost
|
|
bestDefault = default
|
|
bestNominal = nominal
|
|
|
|
return bestDefault, bestNominal
|
|
|
|
|
|
def optimizeWidths(widths):
|
|
"""Given a list of glyph widths, or dictionary mapping glyph width to number of
|
|
glyphs having that, returns a tuple of best CFF default and nominal glyph widths.
|
|
|
|
This algorithm is linear in UPEM+numGlyphs."""
|
|
|
|
if not hasattr(widths, 'items'):
|
|
d = defaultdict(int)
|
|
for w in widths:
|
|
d[w] += 1
|
|
widths = d
|
|
|
|
keys = sorted(widths.keys())
|
|
minw, maxw = keys[0], keys[-1]
|
|
domain = list(range(minw, maxw+1))
|
|
|
|
# Cumulative sum/max forward/backward.
|
|
cumFrqU = cumSum(widths, op=add)
|
|
cumMaxU = cumSum(widths, op=max)
|
|
cumFrqD = cumSum(widths, op=add, decreasing=True)
|
|
cumMaxD = cumSum(widths, op=max, decreasing=True)
|
|
|
|
# Cost per nominal choice, without default consideration.
|
|
nomnCostU = missingdict(lambda x: cumFrqU[x] + cumFrqU[x-108] + cumFrqU[x-1132]*3)
|
|
nomnCostD = missingdict(lambda x: cumFrqD[x] + cumFrqD[x+108] + cumFrqD[x+1132]*3)
|
|
nomnCost = missingdict(lambda x: nomnCostU[x] + nomnCostD[x] - widths[x])
|
|
|
|
# Cost-saving per nominal choice, by best default choice.
|
|
dfltCostU = missingdict(lambda x: max(cumMaxU[x], cumMaxU[x-108]*2, cumMaxU[x-1132]*5))
|
|
dfltCostD = missingdict(lambda x: max(cumMaxD[x], cumMaxD[x+108]*2, cumMaxD[x+1132]*5))
|
|
dfltCost = missingdict(lambda x: max(dfltCostU[x], dfltCostD[x]))
|
|
|
|
# Combined cost per nominal choice.
|
|
bestCost = missingdict(lambda x: nomnCost[x] - dfltCost[x])
|
|
|
|
# Best nominal.
|
|
nominal = min(domain, key=lambda x: bestCost[x])
|
|
|
|
# Work back the best default.
|
|
bestC = bestCost[nominal]
|
|
dfltC = nomnCost[nominal] - bestCost[nominal]
|
|
ends = []
|
|
if dfltC == dfltCostU[nominal]:
|
|
starts = [nominal, nominal-108, nominal-1131]
|
|
for start in starts:
|
|
while cumMaxU[start] and cumMaxU[start] == cumMaxU[start-1]:
|
|
start -= 1
|
|
ends.append(start)
|
|
else:
|
|
starts = [nominal, nominal+108, nominal+1131]
|
|
for start in starts:
|
|
while cumMaxD[start] and cumMaxD[start] == cumMaxD[start+1]:
|
|
start += 1
|
|
ends.append(start)
|
|
default = min(ends, key=lambda default: byteCost(widths, default, nominal))
|
|
|
|
return default, nominal
|
|
|
|
def main(args=None):
|
|
"""Calculate optimum defaultWidthX/nominalWidthX values"""
|
|
|
|
import argparse
|
|
parser = argparse.ArgumentParser(
|
|
"fonttools cffLib.width",
|
|
description=main.__doc__,
|
|
)
|
|
parser.add_argument('inputs', metavar='FILE', type=str, nargs='+',
|
|
help="Input TTF files")
|
|
parser.add_argument('-b', '--brute-force', dest="brute", action="store_true",
|
|
help="Use brute-force approach (VERY slow)")
|
|
|
|
args = parser.parse_args(args)
|
|
|
|
for fontfile in args.inputs:
|
|
font = TTFont(fontfile)
|
|
hmtx = font['hmtx']
|
|
widths = [m[0] for m in hmtx.metrics.values()]
|
|
if args.brute:
|
|
default, nominal = optimizeWidthsBruteforce(widths)
|
|
else:
|
|
default, nominal = optimizeWidths(widths)
|
|
print("glyphs=%d default=%d nominal=%d byteCost=%d" % (len(widths), default, nominal, byteCost(widths, default, nominal)))
|
|
|
|
if __name__ == '__main__':
|
|
import sys
|
|
if len(sys.argv) == 1:
|
|
import doctest
|
|
sys.exit(doctest.testmod().failed)
|
|
main()
|