๐Ÿ— KeyzHub
19Keys ยท community archive
12581 bytes raw
  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
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
#!/usr/bin/env python3
"""fonts.py โ€” HIGH LVL Clipper custom-font registry.

The owner keeps a real collection of downloaded display / hand / mono / serif faces
in ~/Library/Fonts (and a few loose ones in ~/Downloads). This tool scans those
locations, pulls the REAL family name out of each font file (the name libass /
CoreText resolve by โ€” NOT the filename), classifies it, flags demo/trial licences,
and writes `fonts.json`. The dashboard + render tools read that registry so the
owner's own type shows up as presets โ€” never "the regular fonts everybody uses".

Two jobs:
  * CLI  (`python3 tools/fonts.py`):  copy the loose Downloads fonts into
    ~/Library/Fonts (so fontconfig/CoreText can resolve them by family), scan every
    location, and (re)write fonts.json.  Uses fontTools if importable, else a tiny
    stdlib 'name'-table parser, else `fc-scan`.
  * import (`import fonts`):  stdlib-only helpers the server uses at runtime โ€”
    load(), families(), resolve(name).  NO fontTools needed to import.

fonts.json entry: {family, file, style_hint: display|grotesk|mono|hand|serif,
                   commercial: ok|verify, subfamily}
"""
import json
import os
import shutil
import subprocess
from pathlib import Path

HOME = Path.home()
LIBRARY = HOME / "Library" / "Fonts"
DOWNLOADS = HOME / "Downloads"
STUDIO = Path(__file__).resolve().parent.parent
LOCAL_FONTS = STUDIO / "fonts"                 # optional in-repo drop dir (scanned if present)
REGISTRY = STUDIO / "fonts.json"
FONT_EXTS = (".otf", ".ttf", ".ttc", ".TTF", ".OTF", ".TTC")

# The owner's generic families โ€” "the regular fonts everybody uses". Kept off the
# registry on purpose (his standing rule). Everything else in his collection stays.
DENY_FAMILIES = {"roboto", "arial", "helvetica", "helvetica neue", "times new roman"}

# Loose fonts that live in ~/Downloads (not scanned by fontconfig) and must be copied
# into ~/Library/Fonts before libass/CoreText can resolve them by family name.
DOWNLOADS_FONTS = [
    "Alycidon-Condensed.otf", "OptimusPrincepsSemiBold.ttf", "The Last Of Us Rough.ttf",
    "VCR_OSD_MONO_1.001.ttf", "italic-hand.medium.otf",
]


# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ classification โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def classify(family, filename, fixed_pitch=False):
    """display | grotesk | mono | hand | serif โ€” from the family/file name + fixed-pitch flag."""
    s = f"{family} {filename}".lower()
    if fixed_pitch or any(k in s for k in ("mono", "typewriter", "printer", "vcr",
                                           "dos vga", "courier", "pixel", " code")):
        return "mono"
    if any(k in s for k in ("hand", "cursive", "script", "writing", "apple", "erratic",
                            "homemade", "engineer", "delafield", "marker", "brush",
                            "signature", "scrawl")):
        return "hand"
    if any(k in s for k in ("princeps", "optimus", "serif", "didot", "garamond",
                            "baskerville", "playfair", "trajan", "roman", "georgia")):
        return "serif"
    if any(k in s for k in ("grotesk", "haas", "wave", " sans", "gothic", "neue", "grotesque")):
        return "grotesk"
    return "display"


def commercial_flag(family, filename):
    """'verify' for anything that reads as a demo/trial cut, else 'ok'."""
    s = f"{family} {filename}".lower()
    return "verify" if any(k in s for k in ("demo", "trial", "preview", "eval")) else "ok"


def _rep_score(subfamily, filename):
    """Higher = better representative file for a family (prefer upright Regular .otf)."""
    sub = (subfamily or "").lower()
    fn = filename.lower()
    score = 0.0
    if "italic" in sub or "italic" in fn or "oblique" in sub:
        score -= 60
    if sub in ("regular", "book", ""):
        score += 45
    elif "regular" in sub:
        score += 40
    elif "medium" in sub:
        score += 28
    elif "semibold" in sub or "semi bold" in sub:
        score += 16
    elif "book" in sub:
        score += 30
    # de-prioritise the extreme weights / widths as the family's face
    for k in ("thin", "black", "heavy", "condensed", "expanded", "extended", "light", "super"):
        if k in sub:
            score -= 8
    if fn.endswith(".otf"):
        score += 5
    score -= len(fn) * 0.02     # prefer the short/base filename
    return score


# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ name extraction โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _names_fonttools(path):
    """(family, subfamily, fixed_pitch) via fontTools, or None if unavailable/failed."""
    try:
        from fontTools.ttLib import TTFont
    except Exception:
        return None
    try:
        f = TTFont(str(path), fontNumber=0, lazy=True)
        nm = f["name"]

        def g(*ids):
            for i in ids:
                r = nm.getDebugName(i)
                if r:
                    return r.strip()
            return None
        fam = g(16, 1)
        sub = g(17, 2)
        fixed = bool(f["post"].isFixedPitch) if "post" in f else False
        f.close()
        return (fam, sub, fixed) if fam else None
    except Exception:
        return None


def _names_stdlib(path):
    """Minimal 'name'-table parser (stdlib only) for TTF/OTF. Returns (family, subfamily, False)."""
    import struct
    try:
        data = Path(path).read_bytes()
    except Exception:
        return None
    try:
        if data[:4] == b"ttcf":          # collection โ€” jump to first font's offset table
            off = struct.unpack(">I", data[12:16])[0]
        else:
            off = 0
        num_tables = struct.unpack(">H", data[off + 4:off + 6])[0]
        rec = off + 12
        name_off = None
        for _ in range(num_tables):
            tag = data[rec:rec + 4]
            if tag == b"name":
                name_off = struct.unpack(">I", data[rec + 8:rec + 12])[0]
                break
            rec += 16
        if name_off is None:
            return None
        count = struct.unpack(">H", data[name_off + 2:name_off + 4])[0]
        storage = name_off + struct.unpack(">H", data[name_off + 4:name_off + 6])[0]
        found = {}
        for i in range(count):
            r = name_off + 6 + i * 12
            pid, eid, lid, nid, length, soff = struct.unpack(">HHHHHH", data[r:r + 12])
            raw = data[storage + soff:storage + soff + length]
            try:
                txt = raw.decode("utf-16-be") if (pid == 3 or pid == 0) else raw.decode("latin-1")
            except Exception:
                continue
            txt = txt.strip()
            if nid in (16, 1, 17, 2) and txt:
                found.setdefault(nid, txt)
        fam = found.get(16) or found.get(1)
        sub = found.get(17) or found.get(2)
        return (fam, sub, False) if fam else None
    except Exception:
        return None


def _names_fcscan(path):
    """Last-resort family via `fc-scan`."""
    try:
        out = subprocess.run(["fc-scan", "--format", "%{family}\n%{style}\n", str(path)],
                             capture_output=True, text=True, timeout=15).stdout.splitlines()
        fam = out[0].split(",")[0].strip() if out else None
        sub = out[1].split(",")[0].strip() if len(out) > 1 else None
        return (fam, sub, False) if fam else None
    except Exception:
        return None


def font_names(path):
    """Best-effort (family, subfamily, fixed_pitch). fontTools โ†’ stdlib โ†’ fc-scan."""
    for fn in (_names_fonttools, _names_stdlib, _names_fcscan):
        r = fn(path)
        if r and r[0]:
            return r
    return None


# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ scan โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def _iter_font_files():
    for d in (LIBRARY, DOWNLOADS, LOCAL_FONTS):
        if not d.exists():
            continue
        # ~/Downloads: only the loose top-level font files (don't crawl the whole folder)
        it = d.glob("*") if d == DOWNLOADS else d.rglob("*")
        for p in sorted(it):
            if p.is_file() and p.suffix in FONT_EXTS:
                yield p


def copy_downloads():
    """Copy the loose Downloads fonts into ~/Library/Fonts (copy, never move). Returns list copied."""
    LIBRARY.mkdir(parents=True, exist_ok=True)
    copied = []
    for name in DOWNLOADS_FONTS:
        src = DOWNLOADS / name
        if not src.exists():
            continue
        dst = LIBRARY / name
        try:
            if dst.exists() and dst.stat().st_size == src.stat().st_size:
                copied.append(name)           # already present (idempotent)
                continue
            shutil.copy2(src, dst)
            copied.append(name)
        except Exception:
            pass
    return copied


def scan():
    """Scan all locations โ†’ deduped list of family entries (one representative file each)."""
    fams = {}   # family -> {best_score, entry, files:[...]}
    for p in _iter_font_files():
        nm = font_names(p)
        if not nm:
            continue
        family, sub, fixed = nm
        if not family or family.lower() in DENY_FAMILIES:
            continue
        score = _rep_score(sub, p.name)
        cur = fams.get(family)
        entry = {
            "family": family,
            "file": str(p),
            "style_hint": classify(family, p.name, fixed),
            "commercial": commercial_flag(family, p.name),
            "subfamily": sub or "Regular",
        }
        if cur is None or score > cur["_score"]:
            # keep the winning representative but preserve a demo/verify flag if ANY cut is a demo
            if cur and cur["entry"]["commercial"] == "verify":
                entry["commercial"] = "verify"
            fams[family] = {"_score": score, "entry": entry}
        elif entry["commercial"] == "verify":
            cur["entry"]["commercial"] = "verify"
    out = [v["entry"] for v in fams.values()]
    out.sort(key=lambda e: (e["style_hint"], e["family"].lower()))
    return out


def build(write=True):
    copied = copy_downloads()
    fonts = scan()
    reg = {
        "generated_from": [str(LIBRARY), str(DOWNLOADS) + " (loose)", str(LOCAL_FONTS)],
        "copied_from_downloads": copied,
        "note": "Real family names (libass/CoreText resolve by family, not filename). "
                "commercial:'verify' = demo/trial cut, confirm a licence before commercial use.",
        "count": len(fonts),
        "fonts": fonts,
    }
    if write:
        REGISTRY.write_text(json.dumps(reg, indent=2, ensure_ascii=False))
    return reg


# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ runtime helpers (stdlib only) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
_CACHE = None


def load():
    """Read fonts.json (cached). Returns the registry dict, or a stub if absent."""
    global _CACHE
    if _CACHE is None:
        try:
            _CACHE = json.loads(REGISTRY.read_text())
        except Exception:
            _CACHE = {"fonts": [], "count": 0}
    return _CACHE


def families():
    """List of {family, file, style_hint, commercial} from the registry."""
    return load().get("fonts", [])


def resolve(name):
    """Registry entry for a family name (exact, else case-insensitive), or None."""
    fonts = families()
    for e in fonts:
        if e["family"] == name:
            return e
    low = (name or "").lower()
    for e in fonts:
        if e["family"].lower() == low:
            return e
    return None


def file_for(name):
    """Absolute font-file path for a family (registry file, else fc-match), or None."""
    e = resolve(name)
    if e and Path(e["file"]).exists():
        return e["file"]
    try:
        r = subprocess.run(["fc-match", "-f", "%{file}", name],
                           capture_output=True, text=True, timeout=10)
        p = (r.stdout or "").strip()
        return p or None
    except Exception:
        return None


if __name__ == "__main__":
    reg = build()
    print(f"fonts.json โ†’ {REGISTRY}")
    print(f"copied from Downloads: {reg['copied_from_downloads']}")
    print(f"registered {reg['count']} families:")
    for e in reg["fonts"]:
        flag = "  โš  verify-licence" if e["commercial"] == "verify" else ""
        print(f"  [{e['style_hint']:8s}] {e['family']}{flag}")