tinydoc-5m / tinydoc /synth /fonts.py
harryfrz's picture
tinydoc-5m v4: OmniDocBench EN text edit 0.117 (v3 0.127), inline maths as text, invoice summary tables
e24bf15 verified
Raw History Blame Contribute Delete
4.64 kB
"""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)