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)