File size: 8,319 Bytes
df84e45 | 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 | """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)
|