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/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
+ }