File size: 4,643 Bytes
4dce182
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e24bf15
 
4dce182
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e24bf15
4dce182
 
e24bf15
 
 
 
 
4dce182
 
 
9d054d4
4dce182
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e24bf15
 
 
 
 
 
 
4dce182
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
"""Font bank over the openly licensed fonts fetched by scripts/fetch_fonts.py.

Families are split deterministically into train / val so evaluation measures unseen fonts.
"""

import zlib
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path

from fontTools.ttLib import TTFont
from PIL import ImageFont

MONO_HINTS = ("mono", "courier", "vt323", "inconsolata", "anonymouspro", "cousine", "sharetech", "dotgothic", "spacemono", "ptmono")
VAL_PERCENT = 15
# Maths / symbol fonts: never a line's text font, only the fallback for glyphs it lacks (in every split).
FALLBACK_FAMILIES = ("notosansmath", "stixtwotext")


@dataclass(frozen=True)
class FontFace:
    path: str
    family: str
    mono: bool
    italic: bool
    variable: bool
    bold_file: bool


@lru_cache(maxsize=None)
def coverage(path: str) -> frozenset:
    font = TTFont(path, lazy=True, fontNumber=0)
    cmap = font.getBestCmap() or {}
    return frozenset(chr(c) for c in cmap)


@lru_cache(maxsize=4096)
def load_font(path: str, size: int, bold: bool) -> ImageFont.FreeTypeFont:
    font = ImageFont.truetype(path, size)
    if "[" in Path(path).name:
        try:
            axes = font.get_variation_axes()
            values = []
            for ax in axes:
                name = ax.get("name", b"")
                name = name.decode() if isinstance(name, bytes) else str(name)
                if name.lower().startswith("weight"):
                    values.append(max(ax["minimum"], min(ax["maximum"], 700 if bold else 400)))
                else:
                    values.append(ax["default"])
            font.set_variation_by_axes(values)
        except (OSError, KeyError):
            pass
    return font


class FontBank:
    def __init__(self, root="fonts", split="train"):
        faces, self.fallbacks = [], []
        for fam_dir in sorted(p for p in Path(root).glob("*/*") if p.is_dir()):
            fam = fam_dir.name
            if fam in FALLBACK_FAMILIES:
                self.fallbacks += [FontFace(str(f), fam, False, False, "[" in f.name, False)
                                   for f in sorted(fam_dir.glob("*.[ot]tf")) if not f.name.startswith("._")
                                   and "Italic" not in f.name and ("Bold" not in f.name or "[" in f.name)]
                continue
            is_val = zlib.crc32(fam.encode()) % 100 < VAL_PERCENT
            if split != "all" and is_val != (split == "val"):
                continue
            for f in sorted(f for f in fam_dir.glob("*.[ot]tf") if not f.name.startswith("._")):   # skip macOS metadata files
                n = f.name
                faces.append(FontFace(
                    path=str(f), family=fam,
                    mono=any(h in fam for h in MONO_HINTS),
                    italic="Italic" in n,
                    variable="[" in n,
                    bold_file="Bold" in n and not any(k in n for k in ("Semi", "Extra", "Ultra")),
                ))
        if not faces:
            raise FileNotFoundError(f"no fonts under {root}/ for split={split}; run scripts/fetch_fonts.py")
        self.faces = faces
        self.families = sorted({f.family for f in faces})

    def pick(self, rng, mono=None, bold=False, italic_ok=False):
        """Returns (face, bold_flag). Prefers a real bold file, else a variable font's weight axis."""
        fams = [f for f in self.families if mono is None or any(h in f for h in MONO_HINTS) == mono] or self.families
        fam = fams[rng.integers(len(fams))]
        cands = [f for f in self.faces if f.family == fam and (italic_ok or not f.italic)] or \
                [f for f in self.faces if f.family == fam]
        if bold:
            bolds = [f for f in cands if f.bold_file]
            if bolds:
                return bolds[rng.integers(len(bolds))], False
            var = [f for f in cands if f.variable]
            if var:
                return var[0], True
        regular = [f for f in cands if not f.bold_file] or cands
        return regular[rng.integers(len(regular))], False

    @staticmethod
    def font(face: FontFace, size: int, bold_axis: bool = False):
        return load_font(face.path, int(size), bold_axis)

    def fallback(self, ch: str):
        """A maths / symbol face that has `ch`, for glyphs the text font lacks (None when none has it)."""
        for f in self.fallbacks:
            if ch in coverage(f.path):
                return f
        return None

    @staticmethod
    def supported(face: FontFace, text: str) -> str:
        cov = coverage(face.path)
        return "".join(c if (c in cov or c == " ") else "" for c in text)