DesignGym / core /fonts.py
AdithyaSK's picture
AdithyaSK HF Staff
Serve Crello corpus aafdb0e86816 from the mounted bucket
df84e45 verified
Raw History Blame Contribute Delete
8.32 kB
"""The font registry: one set of font files for the renderer and the browser.
Crello ships the fonts its templates use as a single pickle (263 families,
TTF and OTF). `prepare` extracts them into the task pack as plain files, which
buys two things: the server can hand the exact same bytes to the browser editor
through `@font-face`, and skia loads them without unpickling 60 MB per process.
Style fallback is deliberately the *browser's* behaviour with
`font-synthesis: none`: a family without a bold face renders bold text in its
regular face. The editor sets that CSS property, so the two renderers agree
instead of the browser inventing a fake bold that skia would not draw.
"""
from __future__ import annotations
import json
import re
import threading
from functools import lru_cache
from pathlib import Path
import skia
STYLES = ("regular", "bold", "italic", "bolditalic")
DEFAULT_FAMILY = "Montserrat"
# Crello's family spellings that do not match the font files' own names.
_ALIASES = {
"arkana script": "Arkana",
"blogger": "Blogger Sans",
"delius swash": "Delius Swash Caps",
"elsie swash": "Elsie Swash Caps",
"gluk glametrix": "Gluk Foglihtenno06",
"gluk znikomitno25": "Gluk Foglihtenno06",
"im fell": "Im Fell Dw Pica Sc",
"medieval sharp": "Medievalsharp",
"playlist caps": "Playlist",
"rissa typeface": "Rissatypeface",
"selima": "Selima Script",
"six": "Six Caps",
"v t323": "Vt323",
}
def style_key(bold: bool, italic: bool) -> str:
if bold and italic:
return "bolditalic"
if bold:
return "bold"
if italic:
return "italic"
return "regular"
def slug(family: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", family.lower()).strip("-")
class FontRegistry:
"""Family -> style -> font file, plus cached skia typefaces."""
def __init__(self, root: Path | str | None):
self.root = Path(root) if root else None
self._files: dict[str, dict[str, str]] = {}
self._lower: dict[str, str] = {}
self._lock = threading.Lock()
self._typefaces: dict[tuple[str, str], skia.Typeface] = {}
manifest = self.root / "manifest.json" if self.root else None
if manifest and manifest.exists():
self._files = json.loads(manifest.read_text())["families"]
self._lower = {name.lower(): name for name in self._files}
# ------------------------------------------------------------- lookup
@property
def available(self) -> bool:
return bool(self._files)
def families(self) -> list[str]:
return sorted(self._files)
def resolve(self, family: str | None) -> str:
"""Canonical family name, or the default when the family is unknown."""
if not family:
return DEFAULT_FAMILY if DEFAULT_FAMILY in self._files or not self._files else self.families()[0]
name = str(family).strip().strip("'\"")
low = name.replace("_", " ").lower()
for suffix in (" bold", " regular", " light", " italic", " medium"):
if low.endswith(suffix):
low = low[: -len(suffix)]
low = _ALIASES.get(low, low).lower()
if low in self._lower:
return self._lower[low]
if not self._files:
return name
return DEFAULT_FAMILY if DEFAULT_FAMILY in self._files else self.families()[0]
def knows(self, family: str | None) -> bool:
if not family:
return False
low = str(family).strip().replace("_", " ").lower()
low = _ALIASES.get(low, low).lower()
return low in self._lower
def styles(self, family: str) -> list[str]:
return [s for s in STYLES if s in self._files.get(self.resolve(family), {})]
def effective_style(self, family: str, bold: bool, italic: bool) -> str:
"""The face actually drawn, following `font-synthesis: none` semantics."""
have = self._files.get(self.resolve(family), {})
want = style_key(bold, italic)
if want in have or not have:
return want if want in have else "regular"
# Browsers keep the closest face on the axis that exists.
if want == "bolditalic":
for alt in ("bold", "italic"):
if alt in have:
return alt
return "regular"
def file_path(self, family: str, style: str) -> Path | None:
if not self.root:
return None
rel = self._files.get(self.resolve(family), {}).get(style)
return self.root / rel if rel else None
def typeface(self, family: str, bold: bool = False, italic: bool = False) -> skia.Typeface:
fam = self.resolve(family)
style = self.effective_style(fam, bold, italic)
key = (fam, style)
with self._lock:
cached = self._typefaces.get(key)
if cached is not None:
return cached
path = self.file_path(fam, style)
if path is not None and path.exists():
tf = skia.Typeface.MakeFromData(skia.Data.MakeWithCopy(path.read_bytes()))
else:
tf = None
if tf is None:
# No pack (unit tests, a bare checkout): skia's default face keeps
# layout deterministic, it just is not the right typeface.
tf = skia.Typeface.MakeDefault()
self._typefaces[key] = tf
return tf
# --------------------------------------------------------- measurement
def font(self, family: str, size: float, bold: bool = False, italic: bool = False) -> skia.Font:
font = skia.Font(self.typeface(family, bold, italic), max(1.0, float(size)))
font.setSubpixel(True)
font.setEdging(skia.Font.Edging.kAntiAlias)
font.setHinting(skia.FontHinting.kNone)
return font
def advance(
self,
text: str,
family: str,
size: float,
bold: bool = False,
italic: bool = False,
letter_spacing: float = 0.0,
) -> float:
"""Advance width of one line, with CSS letter-spacing (after every glyph)."""
if not text:
return 0.0
font = self.font(family, size, bold, italic)
glyphs = font.textToGlyphs(text)
return float(sum(font.getWidths(glyphs))) + letter_spacing * len(text)
def metrics(self, family: str, size: float, bold: bool = False, italic: bool = False):
"""(ascent, descent) as positive numbers, the values CSS `line-height: normal` uses."""
m = self.font(family, size, bold, italic).getMetrics()
return -m.fAscent, m.fDescent
# --------------------------------------------------------------- extraction
def extract_crello_fonts(pickle_path: Path | str, out_dir: Path | str) -> dict:
"""Unpack crello's `fonts.pickle` into files plus a manifest."""
import pickle
out = Path(out_dir)
out.mkdir(parents=True, exist_ok=True)
with open(pickle_path, "rb") as fh:
fonts = pickle.load(fh)
families: dict[str, dict[str, str]] = {}
for family, faces in sorted(fonts.items()):
entry: dict[str, str] = {}
for face in faces:
weight = face.get("fontWeight", "regular")
fstyle = face.get("fontStyle", "regular")
if fstyle in ("bold", "italic", "bolditalic"):
key = fstyle
else:
key = "bold" if weight == "bold" else "regular"
if key in entry:
continue
data: bytes = face["bytes"]
ext = ".otf" if data[:4] == b"OTTO" else ".ttf"
rel = f"{slug(family)}/{key}{ext}"
(out / rel).parent.mkdir(parents=True, exist_ok=True)
(out / rel).write_bytes(data)
entry[key] = rel
if "regular" not in entry and entry:
# A handful of display faces only ship one cut; let it stand in.
entry["regular"] = next(iter(entry.values()))
families[family] = entry
manifest = {"source": "cyberagent/crello resources/fonts.pickle", "families": families}
(out / "manifest.json").write_text(json.dumps(manifest, indent=1))
return manifest
@lru_cache(maxsize=4)
def registry_for(root: str | None) -> FontRegistry:
return FontRegistry(root)