1#!/usr/bin/env python3
2
3# SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
4#
5# SPDX-License-Identifier: LicenseRef-MutuaL-1.2
6
7"""Compute Golden Ratio Typography values for one font and one geometry.
8
9Metric source, choose one:
10 --font path/to/font.woff2
11 --mu 2.04 [--x-ratio 0.72]
12
13Geometry, choose one:
14 --size 16 --width 640
15 --size 16 --cpl 65
16
17Examples:
18 python3 scripts/compute_grt.py --font fonts/Inter-Regular.woff2 --size 16 --width 640
19 python3 scripts/compute_grt.py --font fonts/Inter-Regular.woff2 --size 16 --cpl 65
20 python3 scripts/compute_grt.py --font fonts/Mono.woff2 --sample-file prose.txt --size 15 --width 720
21 python3 scripts/compute_grt.py --mu 2.04 --x-height 545 --cap-height 730 --size 16 --cpl 65
22
23The script intentionally handles one font or metric set per invocation. Run it
24again for another font, size, or measure.
25"""
26
27from __future__ import annotations
28
29import argparse
30import math
31import sys
32from collections import Counter
33from dataclasses import dataclass
34from pathlib import Path
35
36# Golden ratio and GRT constants.
37PHI = (1 + 5**0.5) / 2
38Q_LOWER = 1 + (PHI - 1) / PHI
39GOLDEN_X_RATIO = 1 / PHI
40DEFAULT_WIDTH_FACTOR = 34.0
41ADVISORY_RATIO = 1.9
42
43# English-like prose weights. The values are only weights; they do not need to
44# sum to exactly 100. Space is included because prose rhythm depends on it.
45DEFAULT_PROSE_WEIGHTS: dict[str, float] = {
46 " ": 17.0,
47 "e": 10.2,
48 "t": 7.7,
49 "a": 6.6,
50 "o": 6.3,
51 "i": 5.7,
52 "n": 5.7,
53 "s": 5.3,
54 "r": 5.0,
55 "h": 5.0,
56 "l": 3.3,
57 "d": 3.3,
58 "u": 2.3,
59 "c": 2.2,
60 "m": 2.0,
61 "f": 1.8,
62 "w": 1.7,
63 "g": 1.6,
64 "y": 1.6,
65 "p": 1.5,
66 "b": 1.2,
67 "v": 0.8,
68 "k": 0.5,
69 "j": 0.1,
70 "x": 0.1,
71 "q": 0.1,
72 "z": 0.07,
73 ",": 1.0,
74 ".": 0.7,
75 "'": 0.3,
76 "-": 0.2,
77}
78
79
80@dataclass(frozen=True)
81class Coverage:
82 source: str
83 matched_weight: float
84 total_weight: float
85 missing_characters: tuple[str, ...]
86
87 @property
88 def matched_percent(self) -> float:
89 if self.total_weight == 0:
90 return 0.0
91 return self.matched_weight * 100 / self.total_weight
92
93
94@dataclass(frozen=True)
95class FontMetrics:
96 source: str
97 mu: float
98 units_per_em: int | None
99 avg_advance: float | None
100 x_height: float | None
101 cap_height: float | None
102 x_ratio: float | None
103 x_ratio_source: str
104 coverage: Coverage | None
105
106
107@dataclass(frozen=True)
108class Geometry:
109 size: float
110 width: float
111 cpl: float
112
113
114def positive_float(raw: str) -> float:
115 try:
116 value = float(raw)
117 except ValueError as error:
118 raise argparse.ArgumentTypeError("must be a number") from error
119 if not math.isfinite(value) or value <= 0:
120 raise argparse.ArgumentTypeError(
121 "must be a finite number greater than 0"
122 )
123 return value
124
125
126def ratio_float(raw: str) -> float:
127 value = positive_float(raw)
128 if value <= 1:
129 raise argparse.ArgumentTypeError("must be greater than 1")
130 return value
131
132
133def non_negative_int(raw: str) -> int:
134 try:
135 value = int(raw, 10)
136 except ValueError as error:
137 raise argparse.ArgumentTypeError("must be an integer") from error
138 if value < 0:
139 raise argparse.ArgumentTypeError("must be 0 or greater")
140 return value
141
142
143def normalise_sample_text(text: str) -> str:
144 return "".join(" " if ch.isspace() else ch for ch in text)
145
146
147def weights_from_text(text: str, source: str) -> dict[str, float]:
148 counts = Counter(normalise_sample_text(text))
149 if not counts:
150 raise ValueError(f"{source} is empty")
151 return {ch: float(count) for ch, count in counts.items()}
152
153
154def load_weights(args: argparse.Namespace) -> tuple[str, dict[str, float]]:
155 if args.sample_text is not None:
156 return "sample text", weights_from_text(
157 args.sample_text, "--sample-text"
158 )
159 if args.sample_file is not None:
160 try:
161 text = args.sample_file.read_text(encoding="utf-8")
162 except OSError as error:
163 raise ValueError(
164 f"cannot read --sample-file {args.sample_file}: {error}"
165 ) from error
166 except UnicodeDecodeError as error:
167 raise ValueError(
168 f"--sample-file {args.sample_file} is not valid UTF-8: {error}"
169 ) from error
170 return f"sample file {args.sample_file}", weights_from_text(
171 text, str(args.sample_file)
172 )
173 return "default English prose", dict(DEFAULT_PROSE_WEIGHTS)
174
175
176def glyph_name(font: object, codepoint: int) -> str | None:
177 cmap = font.getBestCmap()
178 if cmap is None:
179 return None
180 glyph = cmap.get(codepoint)
181 if glyph is None:
182 return None
183 if isinstance(glyph, int):
184 glyph_order = font.getGlyphOrder()
185 if 0 <= glyph < len(glyph_order):
186 return glyph_order[glyph]
187 return None
188 return glyph
189
190
191def weighted_average_advance(
192 font: object,
193 weights: dict[str, float],
194 weight_source: str,
195) -> tuple[float, Coverage]:
196 metrics = font["hmtx"].metrics
197 total_width = 0.0
198 matched_weight = 0.0
199 total_weight = 0.0
200 missing_characters: list[str] = []
201
202 for ch, weight in weights.items():
203 if weight <= 0:
204 continue
205 total_weight += weight
206 name = glyph_name(font, ord(ch))
207 if name is None or name not in metrics:
208 missing_characters.append(ch)
209 continue
210 total_width += float(metrics[name][0]) * weight
211 matched_weight += weight
212
213 if total_weight == 0:
214 raise ValueError(f"{weight_source} has no positive weights")
215 if matched_weight == 0:
216 raise ValueError(
217 f"no weighted characters from {weight_source} exist in the font"
218 )
219
220 coverage = Coverage(
221 source=weight_source,
222 matched_weight=matched_weight,
223 total_weight=total_weight,
224 missing_characters=tuple(missing_characters),
225 )
226 return total_width / matched_weight, coverage
227
228
229def os2_heights(
230 font: object,
231) -> tuple[float | None, float | None, str | None, str | None]:
232 os2 = font.get("OS/2")
233 if os2 is None:
234 return None, None, None, None
235
236 sx_height = float(getattr(os2, "sxHeight", 0) or 0)
237 cap_height = float(getattr(os2, "sCapHeight", 0) or 0)
238 x_value = sx_height if sx_height > 0 else None
239 cap_value = cap_height if cap_height > 0 else None
240 x_source = "OS/2 sxHeight" if x_value is not None else None
241 cap_source = "OS/2 sCapHeight" if cap_value is not None else None
242 return x_value, cap_value, x_source, cap_source
243
244
245def glyph_y_max(
246 font: object, candidates: tuple[str, ...]
247) -> tuple[float | None, str | None]:
248 from fontTools.pens.boundsPen import BoundsPen
249
250 glyph_set = font.getGlyphSet()
251 for ch in candidates:
252 name = glyph_name(font, ord(ch))
253 if name is None:
254 continue
255 try:
256 glyph = glyph_set[name]
257 except KeyError:
258 continue
259 pen = BoundsPen(glyph_set)
260 glyph.draw(pen)
261 if pen.bounds is None:
262 continue
263 _x_min, _y_min, _x_max, y_max = pen.bounds
264 if y_max > 0:
265 return float(y_max), f"glyph bounds for {ch!r}"
266 return None, None
267
268
269def measured_x_ratio(
270 font: object,
271) -> tuple[float | None, float | None, float | None, str]:
272 x_height, cap_height, x_source, cap_source = os2_heights(font)
273
274 if x_height is None:
275 x_height, x_source = glyph_y_max(font, ("x", "o"))
276 if cap_height is None:
277 cap_height, cap_source = glyph_y_max(font, ("H", "T"))
278
279 if x_height is None or cap_height is None:
280 return x_height, cap_height, None, "not available"
281 return (
282 x_height,
283 cap_height,
284 x_height / cap_height,
285 f"{x_source} / {cap_source}",
286 )
287
288
289def measure_font(
290 path: Path, weights: dict[str, float], weight_source: str
291) -> FontMetrics:
292 try:
293 from fontTools.ttLib import TTFont, TTLibError
294 except ImportError as error:
295 raise ValueError(
296 "reading --font requires fontTools; install it with "
297 "`python3 -m pip install fonttools brotli`"
298 ) from error
299
300 try:
301 font = TTFont(str(path))
302 except (OSError, TTLibError) as error:
303 raise ValueError(f"cannot read font {path}: {error}") from error
304
305 try:
306 try:
307 units_per_em = int(font["head"].unitsPerEm)
308 _ = font["hmtx"].metrics
309 except KeyError as error:
310 raise ValueError(
311 f"font {path} is missing required table {error}"
312 ) from error
313
314 avg_advance, coverage = weighted_average_advance(
315 font, weights, weight_source
316 )
317 x_height, cap_height, x_ratio, x_ratio_source = measured_x_ratio(font)
318 finally:
319 font.close()
320
321 return FontMetrics(
322 source=f"font {path}",
323 mu=units_per_em / avg_advance,
324 units_per_em=units_per_em,
325 avg_advance=avg_advance,
326 x_height=x_height,
327 cap_height=cap_height,
328 x_ratio=x_ratio,
329 x_ratio_source=x_ratio_source,
330 coverage=coverage,
331 )
332
333
334def with_manual_x_metrics(
335 metrics: FontMetrics,
336 x_ratio: float | None,
337 x_height: float | None,
338 cap_height: float | None,
339) -> FontMetrics:
340 if x_ratio is None and x_height is None and cap_height is None:
341 return metrics
342
343 if x_ratio is not None:
344 return FontMetrics(
345 source=metrics.source,
346 mu=metrics.mu,
347 units_per_em=metrics.units_per_em,
348 avg_advance=metrics.avg_advance,
349 x_height=metrics.x_height,
350 cap_height=metrics.cap_height,
351 x_ratio=x_ratio,
352 x_ratio_source="manual --x-ratio",
353 coverage=metrics.coverage,
354 )
355
356 assert x_height is not None
357 assert cap_height is not None
358 return FontMetrics(
359 source=metrics.source,
360 mu=metrics.mu,
361 units_per_em=metrics.units_per_em,
362 avg_advance=metrics.avg_advance,
363 x_height=x_height,
364 cap_height=cap_height,
365 x_ratio=x_height / cap_height,
366 x_ratio_source="manual --x-height/--cap-height",
367 coverage=metrics.coverage,
368 )
369
370
371def manual_metrics(
372 mu: float,
373 x_ratio: float | None,
374 x_height: float | None,
375 cap_height: float | None,
376) -> FontMetrics:
377 if x_ratio is None and x_height is not None and cap_height is not None:
378 x_ratio = x_height / cap_height
379 x_ratio_source = "manual --x-height/--cap-height"
380 elif x_ratio is not None:
381 x_ratio_source = "manual --x-ratio"
382 else:
383 x_ratio_source = "not supplied"
384
385 return FontMetrics(
386 source="manual --mu",
387 mu=mu,
388 units_per_em=None,
389 avg_advance=None,
390 x_height=x_height,
391 cap_height=cap_height,
392 x_ratio=x_ratio,
393 x_ratio_source=x_ratio_source,
394 coverage=None,
395 )
396
397
398def cpl_for_width(width: float, size: float, mu: float) -> float:
399 assert width > 0
400 assert size > 0
401 assert mu > 0
402 return width * mu / size
403
404
405def width_for_cpl(cpl: float, size: float, mu: float) -> float:
406 assert cpl > 0
407 assert size > 0
408 assert mu > 0
409 return cpl * size / mu
410
411
412def geometry_from_args(args: argparse.Namespace, mu: float) -> Geometry:
413 if args.width is not None:
414 width = args.width
415 cpl = cpl_for_width(width, args.size, mu)
416 else:
417 assert args.cpl is not None
418 cpl = args.cpl
419 width = width_for_cpl(cpl, args.size, mu)
420 return Geometry(size=args.size, width=width, cpl=cpl)
421
422
423def grt_line_height(
424 size: float,
425 width: float,
426 mu: float,
427 x_ratio: float | None,
428 width_factor: float,
429) -> tuple[float, float, float]:
430 """Return base line height, corrected line height, and correction factor.
431
432 From https://grtcalculator.com/math/:
433 width factor x_w = CPL / mu
434 content width w = CPL * f / mu, therefore CPL = w * mu / f
435 h_base = f * (q_lower + (phi - q_lower) * (CPL / x_w))
436
437 The default width factor is 34, matching the public GRT calculator.
438 """
439 assert size > 0
440 assert width > 0
441 assert mu > 0
442 assert width_factor > 0
443
444 cpl = cpl_for_width(width, size, mu)
445 h_base = size * (Q_LOWER + (PHI - Q_LOWER) * (cpl / width_factor))
446 correction = (x_ratio / GOLDEN_X_RATIO) if x_ratio is not None else 1.0
447 h_corrected = h_base * correction
448 return h_base, h_corrected, correction
449
450
451def describe_character(ch: str) -> str:
452 if ch == " ":
453 return "space"
454 if ch.isprintable():
455 return ch
456 return f"U+{ord(ch):04X}"
457
458
459def describe_missing(characters: tuple[str, ...]) -> str:
460 if not characters:
461 return "none"
462 limit = 12
463 shown = ", ".join(describe_character(ch) for ch in characters[:limit])
464 remaining = len(characters) - limit
465 if remaining > 0:
466 return f"{shown}, … ({remaining} more)"
467 return shown
468
469
470def fmt_optional(value: float | int | None, suffix: str = "") -> str:
471 if value is None:
472 return "N/A"
473 if isinstance(value, int):
474 return f"{value}{suffix}"
475 return f"{value:.4f}{suffix}"
476
477
478def print_report(
479 args: argparse.Namespace, metrics: FontMetrics, geometry: Geometry
480) -> tuple[float, float]:
481 h_base, h_corrected, correction = grt_line_height(
482 geometry.size,
483 geometry.width,
484 metrics.mu,
485 metrics.x_ratio,
486 args.width_factor,
487 )
488 spacing_h = h_corrected if args.spacing_source == "corrected" else h_base
489
490 print("Golden Ratio Typography")
491 print("=======================")
492 print(f"metric source: {metrics.source}")
493 print(f"mu: {metrics.mu:.4f} (character constant)")
494 print(f"units/em: {fmt_optional(metrics.units_per_em)}")
495 print(f"avg advance: {fmt_optional(metrics.avg_advance)}")
496 if metrics.coverage is not None:
497 print(f"weights: {metrics.coverage.source}")
498 print(
499 f"coverage: {metrics.coverage.matched_percent:.1f}% weight matched; "
500 f"missing: {describe_missing(metrics.coverage.missing_characters)}"
501 )
502 print(f"x-height: {fmt_optional(metrics.x_height)}")
503 print(f"cap-height: {fmt_optional(metrics.cap_height)}")
504 print(
505 f"x-ratio: {fmt_optional(metrics.x_ratio)} ({metrics.x_ratio_source})"
506 )
507 print(f"correction: {correction:.4f} (x-ratio / 1/phi)")
508 print()
509
510 print("Geometry")
511 print("--------")
512 print(f"font size: {geometry.size:g}px")
513 print(f"measure: {geometry.width:.3f}px")
514 print(f"cpl: {geometry.cpl:.1f}")
515 print(f"width factor: {args.width_factor:g}")
516 print()
517
518 print("Line height")
519 print("-----------")
520 print(f"h_base: {h_base:.3f}px ratio {h_base / geometry.size:.4f}")
521 print(
522 f"h_corrected: {h_corrected:.3f}px ratio {h_corrected / geometry.size:.4f}"
523 )
524 print()
525
526 print(f"Spacing units (from h_{args.spacing_source})")
527 print("-----------------------------")
528 print(f"h/phi^2: {spacing_h / PHI**2:.3f}px")
529 print(f"h/phi: {spacing_h / PHI:.3f}px")
530 print(f"h: {spacing_h:.3f}px")
531 print(f"h*phi: {spacing_h * PHI:.3f}px")
532 print(f"h*phi^2: {spacing_h * PHI**2:.3f}px")
533 print()
534
535 print(f"Type scale (ratio {args.scale_ratio:g}, root {args.root_size:g}px)")
536 print("----------------------------------------")
537 for step in range(-args.scale_steps, args.scale_steps + 1):
538 px = geometry.size * (args.scale_ratio**step)
539 rem = px / args.root_size
540 print(f"step {step:+d}: {px:7.3f}px {rem:.4f}rem")
541
542 if args.reference_cpl:
543 print()
544 print("Measure reference")
545 print("-----------------")
546 for target in args.reference_cpl:
547 width = width_for_cpl(target, geometry.size, metrics.mu)
548 print(f"{target:g} cpl: {width:.1f}px")
549
550 return h_base, h_corrected
551
552
553def maybe_advisory(h_base: float, h_corrected: float, size: float) -> None:
554 assert size > 0
555 ratio = max(h_base, h_corrected) / size
556 if ratio > ADVISORY_RATIO:
557 print(
558 f"advisory: line-height ratio {ratio:.2f} is unusually high; "
559 "confirm --width/--cpl reflects the actual read length, and "
560 "consider h_base.",
561 file=sys.stderr,
562 )
563
564
565def parser() -> argparse.ArgumentParser:
566 p = argparse.ArgumentParser(
567 description=__doc__,
568 formatter_class=argparse.RawDescriptionHelpFormatter,
569 )
570
571 metric = p.add_mutually_exclusive_group(required=True)
572 metric.add_argument(
573 "--font", type=Path, help="path to one font file (woff2/woff/ttf/otf)"
574 )
575 metric.add_argument(
576 "--mu",
577 type=positive_float,
578 help="character constant, used when no font file is available",
579 )
580
581 geometry = p.add_mutually_exclusive_group(required=True)
582 geometry.add_argument(
583 "--width",
584 "--measure",
585 dest="width",
586 type=positive_float,
587 help="content measure in CSS px",
588 )
589 geometry.add_argument(
590 "--cpl",
591 type=positive_float,
592 help="target characters per line; measure is derived from it",
593 )
594
595 samples = p.add_mutually_exclusive_group()
596 samples.add_argument(
597 "--sample-text",
598 help="representative text for weighted advance calculation",
599 )
600 samples.add_argument(
601 "--sample-file",
602 type=Path,
603 help="UTF-8 file of representative text for weighted advance calculation",
604 )
605
606 p.add_argument(
607 "--size", required=True, type=positive_float, help="font size in CSS px"
608 )
609 p.add_argument(
610 "--x-ratio",
611 type=positive_float,
612 help="manual x-height/cap-height ratio, overriding measured font data",
613 )
614 p.add_argument(
615 "--x-height",
616 type=positive_float,
617 help="manual x-height in font units; requires --cap-height",
618 )
619 p.add_argument(
620 "--cap-height",
621 type=positive_float,
622 help="manual cap height in font units; requires --x-height",
623 )
624 p.add_argument(
625 "--width-factor",
626 type=positive_float,
627 default=DEFAULT_WIDTH_FACTOR,
628 help="GRT width factor; default 34",
629 )
630 p.add_argument(
631 "--spacing-source",
632 choices=("base", "corrected"),
633 default="base",
634 help="line height used for spacing units; default base",
635 )
636 p.add_argument(
637 "--scale-steps",
638 type=non_negative_int,
639 default=4,
640 help="type scale steps each direction; default 4",
641 )
642 p.add_argument(
643 "--scale-ratio",
644 type=ratio_float,
645 default=1.2,
646 help="type scale ratio; default 1.2",
647 )
648 p.add_argument(
649 "--root-size",
650 type=positive_float,
651 default=16.0,
652 help="root font size for rem output; default 16",
653 )
654 p.add_argument(
655 "--reference-cpl",
656 type=positive_float,
657 nargs="*",
658 default=[45.0, 55.0, 65.0, 75.0],
659 help="CPL values for measure reference; pass no values to omit",
660 )
661 return p
662
663
664def validate_args(p: argparse.ArgumentParser, args: argparse.Namespace) -> None:
665 height_count = int(args.x_height is not None) + int(
666 args.cap_height is not None
667 )
668 if height_count == 1:
669 p.error("--x-height and --cap-height must be used together")
670 if args.x_ratio is not None and height_count > 0:
671 p.error("--x-ratio cannot be combined with --x-height/--cap-height")
672 if args.mu is not None and (
673 args.sample_text is not None or args.sample_file is not None
674 ):
675 p.error("--sample-text and --sample-file require --font")
676
677
678def main(argv: list[str] | None = None) -> int:
679 p = parser()
680 args = p.parse_args(argv)
681 validate_args(p, args)
682
683 try:
684 if args.font is not None:
685 weight_source, weights = load_weights(args)
686 measured = measure_font(args.font, weights, weight_source)
687 metrics = with_manual_x_metrics(
688 measured, args.x_ratio, args.x_height, args.cap_height
689 )
690 else:
691 assert args.mu is not None
692 metrics = manual_metrics(
693 args.mu, args.x_ratio, args.x_height, args.cap_height
694 )
695 geometry = geometry_from_args(args, metrics.mu)
696 h_base, h_corrected = print_report(args, metrics, geometry)
697 maybe_advisory(h_base, h_corrected, geometry.size)
698 except ValueError as error:
699 p.error(str(error))
700
701 return 0
702
703
704if __name__ == "__main__":
705 sys.exit(main())