compute_grt.py

  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())