normalize-metrics 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +68 -0
- package/bin/normalize-metrics.js +17 -0
- package/dist/args.js +97 -0
- package/dist/config.js +99 -0
- package/dist/discover.js +70 -0
- package/dist/engine.js +65 -0
- package/dist/fs-exists.js +10 -0
- package/dist/glob.js +59 -0
- package/dist/index.js +220 -0
- package/dist/package.json +1 -0
- package/dist/paths.js +22 -0
- package/dist/progress.js +184 -0
- package/dist/report.js +45 -0
- package/dist/runtime.js +49 -0
- package/dist/types.js +1 -0
- package/lib/engine.py +319 -0
- package/package.json +66 -0
package/lib/engine.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Rewrite vertical metrics per ADR 0001 and 0003. Outlines stay unchanged."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from fontTools.pens.boundsPen import BoundsPen
|
|
12
|
+
from fontTools.ttLib import TTCollection, TTFont
|
|
13
|
+
|
|
14
|
+
USE_TYPO_METRICS = 1 << 7
|
|
15
|
+
Progress = Callable[[int, str], None]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def emit(progress: Progress | None, percent: int, phase: str) -> None:
|
|
19
|
+
if progress:
|
|
20
|
+
progress(min(100, max(0, percent)), phase)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def skip_reason(path: str, font: TTFont | None = None) -> str | None:
|
|
24
|
+
if path.lower().endswith(".ttc"):
|
|
25
|
+
return "TTC collections are skipped in v1"
|
|
26
|
+
opened = font
|
|
27
|
+
close = False
|
|
28
|
+
if opened is None:
|
|
29
|
+
opened = TTFont(path, lazy=True)
|
|
30
|
+
close = True
|
|
31
|
+
try:
|
|
32
|
+
if "fvar" in opened:
|
|
33
|
+
return "variable fonts are skipped in v1"
|
|
34
|
+
finally:
|
|
35
|
+
if close:
|
|
36
|
+
opened.close()
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def open_font(path: str) -> TTFont:
|
|
41
|
+
if path.lower().endswith(".ttc"):
|
|
42
|
+
ttc = TTCollection(path)
|
|
43
|
+
for font in ttc.fonts:
|
|
44
|
+
sub = font["name"].getDebugName(2) or ""
|
|
45
|
+
if sub in ("Regular", "Roman"):
|
|
46
|
+
return font
|
|
47
|
+
return ttc.fonts[0]
|
|
48
|
+
return TTFont(path)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def glyph_y(font: TTFont, names: list[str]) -> tuple[int, int] | None:
|
|
52
|
+
gs = font.getGlyphSet()
|
|
53
|
+
for name in names:
|
|
54
|
+
if name not in gs:
|
|
55
|
+
continue
|
|
56
|
+
pen = BoundsPen(gs)
|
|
57
|
+
try:
|
|
58
|
+
gs[name].draw(pen)
|
|
59
|
+
except Exception:
|
|
60
|
+
continue
|
|
61
|
+
if pen.bounds:
|
|
62
|
+
return round(pen.bounds[1]), round(pen.bounds[3])
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def measure_bbox(font: TTFont, progress: Progress | None = None) -> tuple[int, int]:
|
|
67
|
+
gs = font.getGlyphSet()
|
|
68
|
+
y_min, y_max = font["head"].yMin, font["head"].yMax
|
|
69
|
+
names = font.getGlyphOrder()
|
|
70
|
+
total = max(len(names), 1)
|
|
71
|
+
step = max(1, total // 40)
|
|
72
|
+
for index, name in enumerate(names):
|
|
73
|
+
if name not in gs:
|
|
74
|
+
continue
|
|
75
|
+
pen = BoundsPen(gs)
|
|
76
|
+
try:
|
|
77
|
+
gs[name].draw(pen)
|
|
78
|
+
except Exception:
|
|
79
|
+
continue
|
|
80
|
+
if not pen.bounds:
|
|
81
|
+
continue
|
|
82
|
+
y_min = min(y_min, round(pen.bounds[1]))
|
|
83
|
+
y_max = max(y_max, round(pen.bounds[3]))
|
|
84
|
+
if progress and index % step == 0:
|
|
85
|
+
emit(progress, 8 + int(74 * index / total), "analyzing")
|
|
86
|
+
return y_min, y_max
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def snapshot(font: TTFont, cap: int, x_height: int) -> dict:
|
|
90
|
+
upm = font["head"].unitsPerEm
|
|
91
|
+
hhea = font["hhea"]
|
|
92
|
+
above = hhea.ascent - cap
|
|
93
|
+
below = -hhea.descent
|
|
94
|
+
offset = (above - below) / 2
|
|
95
|
+
content = hhea.ascent + below
|
|
96
|
+
imbalance = abs(above - below) / content if content else 1
|
|
97
|
+
offset_pm = offset / upm * 1000
|
|
98
|
+
centered = max(0, min(100, round((1 - imbalance) * 100)))
|
|
99
|
+
grade = "Great" if abs(offset_pm) < 40 else "Bad"
|
|
100
|
+
return {
|
|
101
|
+
"upm": upm,
|
|
102
|
+
"ascent": hhea.ascent,
|
|
103
|
+
"descent": hhea.descent,
|
|
104
|
+
"lineGap": hhea.lineGap,
|
|
105
|
+
"cap": cap,
|
|
106
|
+
"xHeight": x_height,
|
|
107
|
+
"above": round(above / upm * 1000, 1),
|
|
108
|
+
"below": round(below / upm * 1000, 1),
|
|
109
|
+
"offset": round(offset_pm, 1),
|
|
110
|
+
"centered": centered,
|
|
111
|
+
"grade": grade,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def set_name_suffix(font: TTFont, suffix: str = " Normalized") -> None:
|
|
116
|
+
name = font["name"]
|
|
117
|
+
for rec in list(name.names):
|
|
118
|
+
if rec.nameID not in (1, 4, 6):
|
|
119
|
+
continue
|
|
120
|
+
try:
|
|
121
|
+
text = rec.toUnicode()
|
|
122
|
+
except Exception:
|
|
123
|
+
continue
|
|
124
|
+
if rec.nameID == 6:
|
|
125
|
+
new = text if "Normalized" in text else f"{text}-Normalized"
|
|
126
|
+
else:
|
|
127
|
+
new = text if suffix.strip() in text else text + suffix
|
|
128
|
+
name.setName(new, rec.nameID, rec.platformID, rec.platEncID, rec.langID)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def target_line_box(
|
|
132
|
+
cap: int,
|
|
133
|
+
extra: int,
|
|
134
|
+
old_ascent: int,
|
|
135
|
+
old_descent: int,
|
|
136
|
+
old_line_gap: int,
|
|
137
|
+
) -> tuple[int, int, int]:
|
|
138
|
+
"""ADR 0003: center in the old content box when it already fits.
|
|
139
|
+
|
|
140
|
+
Do not invent lineGap. Spend existing table gap only when content must grow
|
|
141
|
+
into it. Grow used height only when even that is not enough.
|
|
142
|
+
"""
|
|
143
|
+
extra = int(round(extra))
|
|
144
|
+
needed = cap + 2 * extra
|
|
145
|
+
old_content = old_ascent + abs(old_descent)
|
|
146
|
+
used_before = old_content + old_line_gap
|
|
147
|
+
if needed <= old_content:
|
|
148
|
+
pad = old_content - cap
|
|
149
|
+
above = pad // 2
|
|
150
|
+
below = pad - above
|
|
151
|
+
return cap + above, -below, old_line_gap
|
|
152
|
+
if needed <= used_before:
|
|
153
|
+
return cap + extra, -extra, used_before - needed
|
|
154
|
+
return cap + extra, -extra, 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def tables_off(
|
|
158
|
+
font: TTFont,
|
|
159
|
+
ascent: int,
|
|
160
|
+
descent: int,
|
|
161
|
+
win_ascent: int,
|
|
162
|
+
win_descent: int,
|
|
163
|
+
line_gap: int,
|
|
164
|
+
) -> bool:
|
|
165
|
+
hhea = font["hhea"]
|
|
166
|
+
os2 = font["OS/2"]
|
|
167
|
+
return not (
|
|
168
|
+
hhea.ascent == ascent
|
|
169
|
+
and hhea.descent == descent
|
|
170
|
+
and hhea.lineGap == line_gap
|
|
171
|
+
and os2.sTypoAscender == ascent
|
|
172
|
+
and os2.sTypoDescender == descent
|
|
173
|
+
and os2.sTypoLineGap == line_gap
|
|
174
|
+
and bool(os2.fsSelection & USE_TYPO_METRICS)
|
|
175
|
+
and os2.usWinAscent == win_ascent
|
|
176
|
+
and os2.usWinDescent == win_descent
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def normalize(font: TTFont, progress: Progress | None = None) -> dict:
|
|
181
|
+
emit(progress, 6, "analyzing")
|
|
182
|
+
h = glyph_y(font, ["H", "uni0048"])
|
|
183
|
+
x = glyph_y(font, ["x", "uni0078"])
|
|
184
|
+
adieresis = glyph_y(font, ["Adieresis", "uni00C4"])
|
|
185
|
+
p = glyph_y(font, ["p", "uni0070"])
|
|
186
|
+
g = glyph_y(font, ["g", "uni0067"])
|
|
187
|
+
y = glyph_y(font, ["y", "uni0079"])
|
|
188
|
+
os2 = font["OS/2"]
|
|
189
|
+
cap = h[1] if h else getattr(os2, "sCapHeight", None) or 700
|
|
190
|
+
ex = x[1] if x else getattr(os2, "sxHeight", None) or round(cap * 0.7)
|
|
191
|
+
bbox_y_min, bbox_y_max = measure_bbox(font, progress)
|
|
192
|
+
mins = [bbox_y_min, font["head"].yMin]
|
|
193
|
+
for pair in (p, g, y):
|
|
194
|
+
if pair:
|
|
195
|
+
mins.append(pair[0])
|
|
196
|
+
descender_depth = -min(mins)
|
|
197
|
+
accent_typical = max(0, adieresis[1] - cap) if adieresis else 0
|
|
198
|
+
extra = max(descender_depth, accent_typical, 0)
|
|
199
|
+
hhea = font["hhea"]
|
|
200
|
+
ascent, descent, line_gap = target_line_box(
|
|
201
|
+
cap, extra, hhea.ascent, hhea.descent, hhea.lineGap
|
|
202
|
+
)
|
|
203
|
+
win_ascent = max(ascent, bbox_y_max, 0)
|
|
204
|
+
win_descent = max(-descent, -bbox_y_min, 0)
|
|
205
|
+
off = tables_off(font, ascent, descent, win_ascent, win_descent, line_gap)
|
|
206
|
+
before = snapshot(font, cap, ex)
|
|
207
|
+
|
|
208
|
+
emit(progress, 85, "rewriting")
|
|
209
|
+
font["hhea"].ascent = ascent
|
|
210
|
+
font["hhea"].descent = descent
|
|
211
|
+
font["hhea"].lineGap = line_gap
|
|
212
|
+
os2.sTypoAscender = ascent
|
|
213
|
+
os2.sTypoDescender = descent
|
|
214
|
+
os2.sTypoLineGap = line_gap
|
|
215
|
+
os2.fsSelection |= USE_TYPO_METRICS
|
|
216
|
+
os2.usWinAscent = win_ascent
|
|
217
|
+
os2.usWinDescent = win_descent
|
|
218
|
+
if os2.version < 4:
|
|
219
|
+
os2.version = 4
|
|
220
|
+
os2.sCapHeight = cap
|
|
221
|
+
os2.sxHeight = ex
|
|
222
|
+
set_name_suffix(font)
|
|
223
|
+
|
|
224
|
+
after = snapshot(font, cap, ex)
|
|
225
|
+
family = font["name"].getDebugName(1) or "Font"
|
|
226
|
+
return {
|
|
227
|
+
"family": family.replace(" Normalized", ""),
|
|
228
|
+
"before": before,
|
|
229
|
+
"after": after,
|
|
230
|
+
"off": off,
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def save_font(font: TTFont, dest: str) -> str:
|
|
235
|
+
flavor = font.flavor
|
|
236
|
+
Path(dest).parent.mkdir(parents=True, exist_ok=True)
|
|
237
|
+
if flavor in ("woff", "woff2"):
|
|
238
|
+
try:
|
|
239
|
+
font.flavor = flavor
|
|
240
|
+
font.save(dest)
|
|
241
|
+
except Exception:
|
|
242
|
+
font.flavor = None
|
|
243
|
+
dest = str(Path(dest).with_suffix(".otf" if "CFF " in font else ".ttf"))
|
|
244
|
+
font.save(dest)
|
|
245
|
+
else:
|
|
246
|
+
font.save(dest)
|
|
247
|
+
return dest
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def parse_args(argv: list[str]) -> tuple[bool, bool, bool, list[str]]:
|
|
251
|
+
inspect = False
|
|
252
|
+
progress = False
|
|
253
|
+
skip_if_good = False
|
|
254
|
+
positional: list[str] = []
|
|
255
|
+
for arg in argv[1:]:
|
|
256
|
+
if arg == "--inspect":
|
|
257
|
+
inspect = True
|
|
258
|
+
elif arg == "--progress":
|
|
259
|
+
progress = True
|
|
260
|
+
elif arg == "--skip-if-good":
|
|
261
|
+
skip_if_good = True
|
|
262
|
+
elif arg in ("-h", "--help"):
|
|
263
|
+
print(
|
|
264
|
+
"usage: engine.py [--inspect] [--progress] [--skip-if-good] <input> [output]",
|
|
265
|
+
file=sys.stderr,
|
|
266
|
+
)
|
|
267
|
+
raise SystemExit(0)
|
|
268
|
+
else:
|
|
269
|
+
positional.append(arg)
|
|
270
|
+
return inspect, progress, skip_if_good, positional
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def make_progress(enabled: bool) -> Progress | None:
|
|
274
|
+
if not enabled:
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
def progress(percent: int, phase: str) -> None:
|
|
278
|
+
print(json.dumps({"event": "progress", "percent": percent, "phase": phase}), file=sys.stderr, flush=True)
|
|
279
|
+
|
|
280
|
+
return progress
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def main() -> None:
|
|
284
|
+
inspect, want_progress, skip_if_good, positional = parse_args(sys.argv)
|
|
285
|
+
if inspect:
|
|
286
|
+
if len(positional) != 1:
|
|
287
|
+
print("usage: engine.py --inspect <input>", file=sys.stderr)
|
|
288
|
+
raise SystemExit(2)
|
|
289
|
+
src, dest = positional[0], None
|
|
290
|
+
elif len(positional) == 2:
|
|
291
|
+
src, dest = positional
|
|
292
|
+
else:
|
|
293
|
+
print("usage: engine.py <input> <output>", file=sys.stderr)
|
|
294
|
+
raise SystemExit(2)
|
|
295
|
+
|
|
296
|
+
progress = make_progress(want_progress)
|
|
297
|
+
emit(progress, 2, "analyzing")
|
|
298
|
+
reason = skip_reason(src)
|
|
299
|
+
if reason:
|
|
300
|
+
print(json.dumps({"skip": reason}))
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
font = open_font(src)
|
|
304
|
+
reason = skip_reason(src, font)
|
|
305
|
+
if reason:
|
|
306
|
+
print(json.dumps({"skip": reason}))
|
|
307
|
+
return
|
|
308
|
+
|
|
309
|
+
result = normalize(font, progress)
|
|
310
|
+
already_good = not result["off"]
|
|
311
|
+
if dest and not inspect and not (skip_if_good and already_good):
|
|
312
|
+
emit(progress, 90, "writing")
|
|
313
|
+
result["output"] = save_font(font, dest)
|
|
314
|
+
emit(progress, 100, "done")
|
|
315
|
+
print(json.dumps(result))
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
if __name__ == "__main__":
|
|
319
|
+
main()
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "normalize-metrics",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Rewrite font vertical metrics so a word sits in the box",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Oles Gergun",
|
|
8
|
+
"url": "https://olesgergun.com"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/gerguno/normalize-metrics.git"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/gerguno/normalize-metrics/issues"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/gerguno/normalize-metrics#readme",
|
|
18
|
+
"keywords": [
|
|
19
|
+
"font",
|
|
20
|
+
"fonts",
|
|
21
|
+
"metrics",
|
|
22
|
+
"vertical-metrics",
|
|
23
|
+
"otf",
|
|
24
|
+
"ttf",
|
|
25
|
+
"woff",
|
|
26
|
+
"typography",
|
|
27
|
+
"cli"
|
|
28
|
+
],
|
|
29
|
+
"bin": {
|
|
30
|
+
"normalize-metrics": "bin/normalize-metrics.js"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"bin",
|
|
34
|
+
"dist",
|
|
35
|
+
"lib/engine.py"
|
|
36
|
+
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"dev": "next dev",
|
|
45
|
+
"build": "next build",
|
|
46
|
+
"start": "next start",
|
|
47
|
+
"cli": "node --experimental-strip-types --experimental-default-type=module --no-warnings=ExperimentalWarning cli/index.ts",
|
|
48
|
+
"test:cli": "node --experimental-strip-types --experimental-default-type=module --test cli/*.test.ts",
|
|
49
|
+
"build:cli": "tsc -p cli/tsconfig.build.json && printf '{\"type\":\"module\"}\\n' > dist/package.json",
|
|
50
|
+
"prepack": "npm run build:cli",
|
|
51
|
+
"prepublishOnly": "npm run test:cli"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/node": "^22.18.1",
|
|
55
|
+
"@types/react": "^19.1.12",
|
|
56
|
+
"@types/react-dom": "^19.1.9",
|
|
57
|
+
"motion": "^13.4.0",
|
|
58
|
+
"next": "^15.5.3",
|
|
59
|
+
"overlayscrollbars": "^2.16.0",
|
|
60
|
+
"overlayscrollbars-react": "^0.5.6",
|
|
61
|
+
"react": "^19.1.0",
|
|
62
|
+
"react-dom": "^19.1.0",
|
|
63
|
+
"sass": "^1.104.1",
|
|
64
|
+
"typescript": "^5.9.2"
|
|
65
|
+
}
|
|
66
|
+
}
|