figkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
figkit/fonts.py ADDED
@@ -0,0 +1,462 @@
1
+ """Font resolution and text measurement.
2
+
3
+ Layout quality lives or dies on knowing how wide a string is. We resolve a
4
+ CSS-ish ``font-family`` list to an actual font file (via fontconfig when
5
+ available, otherwise by scanning the usual directories), then measure with
6
+ real glyph advances from the font's ``hmtx`` table.
7
+
8
+ If no font file can be found at all we fall back to the PostScript core-font
9
+ width tables (Helvetica/Times/Courier), which are close enough for layout.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import functools
15
+ import io
16
+ import os
17
+ import re
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+ from dataclasses import dataclass
22
+
23
+ __all__ = [
24
+ "Font", "FontMetrics", "get_font", "measure_text", "text_extents",
25
+ "register_font", "font_dirs", "clear_cache",
26
+ ]
27
+
28
+ # --------------------------------------------------------------------------
29
+ # Core-font fallback widths (units per 1000 em, characters 32..126)
30
+ # --------------------------------------------------------------------------
31
+
32
+ _CORE = {
33
+ "helvetica": (
34
+ "278 278 355 556 556 889 667 191 333 333 389 584 278 333 278 278 "
35
+ "556 556 556 556 556 556 556 556 556 556 278 278 584 584 584 556 "
36
+ "1015 667 667 722 722 667 611 778 722 278 500 667 556 833 722 778 "
37
+ "667 778 722 667 611 722 667 944 667 667 611 278 278 278 469 556 "
38
+ "333 556 556 500 556 556 278 556 556 222 222 500 222 833 556 556 "
39
+ "556 556 333 500 278 556 500 722 500 500 500 334 260 334 584"),
40
+ "helvetica-bold": (
41
+ "278 333 474 556 556 889 722 238 333 333 389 584 278 333 278 278 "
42
+ "556 556 556 556 556 556 556 556 556 556 333 333 584 584 584 611 "
43
+ "975 722 722 722 722 667 611 778 722 278 556 722 611 833 722 778 "
44
+ "667 778 722 667 611 722 667 944 667 667 611 333 278 333 584 556 "
45
+ "333 556 611 556 611 556 333 611 611 278 278 556 278 889 611 611 "
46
+ "611 611 389 556 333 611 556 778 556 556 500 389 280 389 584"),
47
+ "times": (
48
+ "250 333 408 500 500 833 778 180 333 333 500 564 250 333 250 278 "
49
+ "500 500 500 500 500 500 500 500 500 500 278 278 564 564 564 444 "
50
+ "921 722 667 667 722 611 556 722 722 333 389 722 611 889 722 722 "
51
+ "556 722 667 556 611 722 722 944 722 722 611 333 278 333 469 500 "
52
+ "333 444 500 444 500 444 333 500 500 278 278 500 278 778 500 500 "
53
+ "500 500 333 389 278 500 500 722 500 500 444 480 200 480 541"),
54
+ "times-bold": (
55
+ "250 333 555 500 500 1000 833 278 333 333 500 570 250 333 250 278 "
56
+ "500 500 500 500 500 500 500 500 500 500 333 333 570 570 570 500 "
57
+ "930 722 667 722 722 667 611 778 778 389 500 778 667 944 722 778 "
58
+ "611 778 722 556 667 722 722 1000 722 722 667 333 278 333 581 500 "
59
+ "333 500 556 444 556 444 333 500 556 278 333 556 278 833 556 500 "
60
+ "556 556 444 389 333 556 500 722 500 500 444 394 220 394 520"),
61
+ }
62
+ _CORE_WIDTHS = {k: [int(x) for x in v.split()] for k, v in _CORE.items()}
63
+
64
+ # Generic family -> concrete candidates, in preference order.
65
+ _GENERIC = {
66
+ "sans-serif": ["Inter", "Helvetica Neue", "Helvetica", "Arial",
67
+ "Liberation Sans", "DejaVu Sans", "FreeSans", "Segoe UI",
68
+ "Roboto", "Noto Sans"],
69
+ "sans": ["Helvetica", "Arial", "Liberation Sans", "DejaVu Sans"],
70
+ "serif": ["Latin Modern Roman", "Georgia", "Times New Roman", "Times",
71
+ "Liberation Serif", "DejaVu Serif", "FreeSerif", "Noto Serif"],
72
+ "monospace": ["SF Mono", "Menlo", "Consolas", "DejaVu Sans Mono",
73
+ "Liberation Mono", "Courier New", "FreeMono", "Noto Sans Mono"],
74
+ "mono": ["DejaVu Sans Mono", "Liberation Mono", "Courier New"],
75
+ "cursive": ["Comic Sans MS", "DejaVu Sans"],
76
+ "system-ui": ["Inter", "Segoe UI", "Helvetica", "DejaVu Sans"],
77
+ "ui-monospace": ["SF Mono", "Menlo", "DejaVu Sans Mono"],
78
+ "ui-sans-serif": ["Inter", "Helvetica", "DejaVu Sans"],
79
+ }
80
+
81
+ _EXTS = (".ttf", ".otf", ".ttc", ".otc")
82
+
83
+ _registered: dict = {} # lowercase family -> {(weight, style): path}
84
+ _dir_index: dict | None = None
85
+
86
+
87
+ def font_dirs() -> list:
88
+ """Directories searched for font files."""
89
+ dirs = []
90
+ env = os.environ.get("FIGKIT_FONT_PATH")
91
+ if env:
92
+ dirs += [d for d in env.split(os.pathsep) if d]
93
+ home = os.path.expanduser("~")
94
+ if sys.platform == "darwin":
95
+ dirs += ["/System/Library/Fonts", "/Library/Fonts",
96
+ os.path.join(home, "Library/Fonts"),
97
+ "/System/Library/Fonts/Supplemental"]
98
+ elif os.name == "nt":
99
+ dirs += [os.path.join(os.environ.get("WINDIR", r"C:\Windows"), "Fonts"),
100
+ os.path.join(os.environ.get("LOCALAPPDATA", ""),
101
+ "Microsoft", "Windows", "Fonts")]
102
+ else:
103
+ dirs += ["/usr/share/fonts", "/usr/local/share/fonts",
104
+ os.path.join(home, ".fonts"),
105
+ os.path.join(home, ".local/share/fonts")]
106
+ return [d for d in dirs if d and os.path.isdir(d)]
107
+
108
+
109
+ def register_font(family: str, path: str, weight="normal", style="normal") -> None:
110
+ """Teach figkit about a font file so it can be measured and embedded."""
111
+ if not os.path.isfile(path):
112
+ raise FileNotFoundError(path)
113
+ key = family.strip().lower()
114
+ _registered.setdefault(key, {})[(_wnum(weight), _snorm(style))] = path
115
+ clear_cache()
116
+
117
+
118
+ def clear_cache() -> None:
119
+ global _dir_index
120
+ _dir_index = None
121
+ _resolve_family.cache_clear()
122
+ _load_font.cache_clear()
123
+ measure_text.cache_clear()
124
+
125
+
126
+ def _wnum(weight) -> int:
127
+ if isinstance(weight, (int, float)):
128
+ return int(weight)
129
+ w = str(weight).lower()
130
+ return {"thin": 100, "extralight": 200, "ultralight": 200, "light": 300,
131
+ "normal": 400, "regular": 400, "book": 400, "medium": 500,
132
+ "semibold": 600, "demibold": 600, "bold": 700, "extrabold": 800,
133
+ "ultrabold": 800, "black": 900, "heavy": 900,
134
+ "lighter": 300, "bolder": 700}.get(w, 400)
135
+
136
+
137
+ def _snorm(style) -> str:
138
+ s = str(style or "normal").lower()
139
+ return "italic" if s in ("italic", "oblique") else "normal"
140
+
141
+
142
+ def split_family(family) -> list:
143
+ """Split a CSS font-family list into concrete candidate names."""
144
+ if family is None:
145
+ return []
146
+ if isinstance(family, (list, tuple)):
147
+ parts = list(family)
148
+ else:
149
+ parts = [p.strip() for p in str(family).split(",")]
150
+ out = []
151
+ for p in parts:
152
+ p = str(p).strip().strip("'\"").strip()
153
+ if not p:
154
+ continue
155
+ low = p.lower()
156
+ if low in _GENERIC:
157
+ out.extend(_GENERIC[low])
158
+ out.append(low)
159
+ else:
160
+ out.append(p)
161
+ return out
162
+
163
+
164
+ # --------------------------------------------------------------------------
165
+ # Locating font files
166
+ # --------------------------------------------------------------------------
167
+
168
+ def _build_dir_index() -> dict:
169
+ """Map ``lowercase filename stem -> path`` for every font we can see."""
170
+ global _dir_index
171
+ if _dir_index is not None:
172
+ return _dir_index
173
+ index: dict = {}
174
+ for root_dir in font_dirs():
175
+ for root, _dirs, files in os.walk(root_dir):
176
+ for fn in files:
177
+ if fn.lower().endswith(_EXTS):
178
+ stem = os.path.splitext(fn)[0].lower()
179
+ index.setdefault(stem, os.path.join(root, fn))
180
+ index.setdefault(re.sub(r"[^a-z0-9]", "", stem),
181
+ os.path.join(root, fn))
182
+ _dir_index = index
183
+ return index
184
+
185
+
186
+ def _fc_match(name: str, weight: int, style: str) -> str | None:
187
+ fc = shutil.which("fc-match")
188
+ if not fc:
189
+ return None
190
+ pattern = name
191
+ if weight >= 600:
192
+ pattern += ":bold"
193
+ if style == "italic":
194
+ pattern += ":italic"
195
+ try:
196
+ out = subprocess.run([fc, "-f", "%{file}", pattern],
197
+ capture_output=True, text=True, timeout=5)
198
+ except Exception:
199
+ return None
200
+ path = out.stdout.strip()
201
+ if path and os.path.isfile(path) and path.lower().endswith(_EXTS):
202
+ # fc-match always answers; make sure the answer is actually related.
203
+ stem = re.sub(r"[^a-z0-9]", "", os.path.splitext(os.path.basename(path))[0].lower())
204
+ want = re.sub(r"[^a-z0-9]", "", name.lower())
205
+ if want[:6] in stem or stem[:6] in want or want in stem:
206
+ return path
207
+ return path if name.lower() in _GENERIC else None
208
+ return None
209
+
210
+
211
+ def _guess_filenames(name: str, weight: int, style: str) -> list:
212
+ base = re.sub(r"[^A-Za-z0-9]", "", name)
213
+ bold = weight >= 600
214
+ suffixes = []
215
+ if bold and style == "italic":
216
+ suffixes = ["BoldItalic", "-BoldItalic", "bi", "-BoldOblique", "Z"]
217
+ elif bold:
218
+ suffixes = ["Bold", "-Bold", "bd", "b", "-Heavy"]
219
+ elif style == "italic":
220
+ suffixes = ["Italic", "-Italic", "i", "-Oblique"]
221
+ else:
222
+ suffixes = ["Regular", "-Regular", "", "-Book", "Book"]
223
+ cands = [base + s for s in suffixes]
224
+ cands += [base + "-" + s.lstrip("-") for s in suffixes if s]
225
+ if not bold and style == "normal":
226
+ cands.append(base)
227
+ return [re.sub(r"[^a-z0-9]", "", c.lower()) for c in cands]
228
+
229
+
230
+ @functools.lru_cache(maxsize=512)
231
+ def _resolve_family(family: str, weight: int, style: str) -> str | None:
232
+ for name in split_family(family):
233
+ reg = _registered.get(name.lower())
234
+ if reg:
235
+ for key in ((weight, style), (weight, "normal"),
236
+ (400, style), (400, "normal")):
237
+ if key in reg:
238
+ return reg[key]
239
+ return next(iter(reg.values()))
240
+ index = _build_dir_index()
241
+ for name in split_family(family):
242
+ for cand in _guess_filenames(name, weight, style):
243
+ if cand in index:
244
+ return index[cand]
245
+ for name in split_family(family):
246
+ hit = _fc_match(name, weight, style)
247
+ if hit:
248
+ return hit
249
+ return None
250
+
251
+
252
+ @dataclass(frozen=True)
253
+ class FontMetrics:
254
+ """Vertical metrics, expressed as a fraction of the em size."""
255
+
256
+ ascent: float = 0.8
257
+ descent: float = 0.2 # positive, measured downward
258
+ line_gap: float = 0.0
259
+ cap_height: float = 0.7
260
+ x_height: float = 0.52
261
+ units_per_em: int = 1000
262
+
263
+ @property
264
+ def line_height(self) -> float:
265
+ return self.ascent + self.descent + self.line_gap
266
+
267
+
268
+ class Font:
269
+ """A measurable font. Use :func:`get_font` rather than constructing one."""
270
+
271
+ def __init__(self, family: str, weight=400, style="normal", path: str = None):
272
+ self.family = family
273
+ self.weight = _wnum(weight)
274
+ self.style = _snorm(style)
275
+ self.path = path
276
+ self._tt = None
277
+ self._cmap = None
278
+ self._hmtx = None
279
+ self._glyphset = None
280
+ self._kern = None
281
+ self._bytes: bytes | None = None
282
+ self.metrics = FontMetrics()
283
+ self._core = self._pick_core()
284
+ if path:
285
+ self._load()
286
+
287
+ # -- loading --------------------------------------------------------
288
+ def _pick_core(self) -> list:
289
+ names = " ".join(split_family(self.family)).lower()
290
+ bold = self.weight >= 600
291
+ if "courier" in names or "mono" in names:
292
+ return [600] * 95
293
+ if "times" in names or "serif" in names or "georgia" in names:
294
+ return _CORE_WIDTHS["times-bold" if bold else "times"]
295
+ return _CORE_WIDTHS["helvetica-bold" if bold else "helvetica"]
296
+
297
+ def _load(self) -> None:
298
+ try:
299
+ from fontTools.ttLib import TTFont, TTCollection
300
+ except ImportError:
301
+ self.path = None
302
+ return
303
+ try:
304
+ # Read into memory rather than handing TTFont a path: lazy=True
305
+ # would otherwise hold an OS file handle open for the process's
306
+ # lifetime, and a figure using many faces can exhaust the limit.
307
+ with open(self.path, "rb") as fh:
308
+ self._bytes = fh.read()
309
+ if self.path.lower().endswith((".ttc", ".otc")):
310
+ coll = TTCollection(io.BytesIO(self._bytes), lazy=True,
311
+ fontNumber=0)
312
+ tt = coll.fonts[0]
313
+ else:
314
+ tt = TTFont(io.BytesIO(self._bytes), lazy=True, fontNumber=0)
315
+ self._tt = tt
316
+ upem = tt["head"].unitsPerEm
317
+ hhea = tt["hhea"]
318
+ asc, desc, gap = hhea.ascent, -hhea.descent, hhea.lineGap
319
+ os2 = tt.get("OS/2")
320
+ if os2 is not None and getattr(os2, "sTypoAscender", 0):
321
+ if getattr(os2, "fsSelection", 0) & 128: # USE_TYPO_METRICS
322
+ asc = os2.sTypoAscender
323
+ desc = -os2.sTypoDescender
324
+ gap = os2.sTypoLineGap
325
+ cap = getattr(os2, "sCapHeight", 0) or 0
326
+ xh = getattr(os2, "sxHeight", 0) or 0
327
+ self.metrics = FontMetrics(
328
+ ascent=asc / upem, descent=desc / upem, line_gap=gap / upem,
329
+ cap_height=(cap / upem) if cap else 0.72 * (asc / upem) / 0.8,
330
+ x_height=(xh / upem) if xh else 0.52,
331
+ units_per_em=upem,
332
+ )
333
+ self._cmap = tt.getBestCmap()
334
+ self._hmtx = tt["hmtx"]
335
+ except Exception:
336
+ self._tt = None
337
+ self.path = None
338
+
339
+ @property
340
+ def available(self) -> bool:
341
+ return self._tt is not None
342
+
343
+ @property
344
+ def upem(self) -> int:
345
+ return self.metrics.units_per_em
346
+
347
+ # -- measurement ----------------------------------------------------
348
+ def glyph_name(self, ch: str):
349
+ if self._cmap is None:
350
+ return None
351
+ return self._cmap.get(ord(ch))
352
+
353
+ def char_advance(self, ch: str) -> float:
354
+ """Advance width of one character, as a fraction of the em."""
355
+ if self._tt is not None:
356
+ gname = self.glyph_name(ch)
357
+ if gname is None:
358
+ for alt in (".notdef",):
359
+ gname = alt
360
+ try:
361
+ return self._hmtx[gname][0] / self.upem
362
+ except Exception:
363
+ return 0.5
364
+ code = ord(ch)
365
+ if 32 <= code <= 126:
366
+ return self._core[code - 32] / 1000.0
367
+ if ch == "\t":
368
+ return 4 * self._core[0] / 1000.0
369
+ if code < 32:
370
+ return 0.0
371
+ if 0x4E00 <= code <= 0x9FFF or 0x3000 <= code <= 0x30FF:
372
+ return 1.0
373
+ return 0.55
374
+
375
+ def string_width(self, text: str, size: float = 1.0,
376
+ letter_spacing: float = 0.0) -> float:
377
+ if not text:
378
+ return 0.0
379
+ total = sum(self.char_advance(c) for c in text) * size
380
+ if letter_spacing:
381
+ total += letter_spacing * (len(text) - 1)
382
+ return total
383
+
384
+ # -- outlines -------------------------------------------------------
385
+ def glyph_set(self):
386
+ if self._glyphset is None and self._tt is not None:
387
+ self._glyphset = self._tt.getGlyphSet()
388
+ return self._glyphset
389
+
390
+ def text_to_path(self, text: str, size: float = 1.0,
391
+ letter_spacing: float = 0.0) -> str:
392
+ """SVG path data for ``text`` on a baseline at the origin (y down)."""
393
+ gs = self.glyph_set()
394
+ if gs is None:
395
+ return ""
396
+ try:
397
+ from fontTools.pens.svgPathPen import SVGPathPen
398
+ from fontTools.pens.transformPen import TransformPen
399
+ except ImportError:
400
+ return ""
401
+ scale = size / self.upem
402
+ pen_out = SVGPathPen(gs, ntos=lambda v: f"{v:.3f}".rstrip("0").rstrip("."))
403
+ x = 0.0
404
+ for ch in text:
405
+ gname = self.glyph_name(ch)
406
+ if gname is None or gname not in gs:
407
+ x += self.char_advance(ch) * size + letter_spacing
408
+ continue
409
+ # flip y (font space is y-up, SVG is y-down) and place at x
410
+ tpen = TransformPen(pen_out, (scale, 0, 0, -scale, x, 0))
411
+ try:
412
+ gs[gname].draw(tpen)
413
+ except Exception:
414
+ pass
415
+ x += self.char_advance(ch) * size + letter_spacing
416
+ return pen_out.getCommands()
417
+
418
+ def font_data(self) -> bytes | None:
419
+ """Raw bytes of the font file (for embedding in exported SVG)."""
420
+ if self._bytes is not None:
421
+ return self._bytes
422
+ if not self.path:
423
+ return None
424
+ try:
425
+ with open(self.path, "rb") as fh:
426
+ self._bytes = fh.read()
427
+ return self._bytes
428
+ except OSError:
429
+ return None
430
+
431
+ def __repr__(self) -> str:
432
+ where = os.path.basename(self.path) if self.path else "core-metrics"
433
+ return f"<Font {self.family!r} {self.weight} {self.style} [{where}]>"
434
+
435
+
436
+ @functools.lru_cache(maxsize=64)
437
+ def _load_font(family: str, weight: int, style: str) -> Font:
438
+ path = _resolve_family(family, weight, style)
439
+ return Font(family, weight, style, path)
440
+
441
+
442
+ def get_font(family=None, weight="normal", style="normal") -> Font:
443
+ """Resolve a font family list to a measurable :class:`Font`."""
444
+ fam = family if isinstance(family, str) else ", ".join(family or ["sans-serif"])
445
+ return _load_font(fam or "sans-serif", _wnum(weight), _snorm(style))
446
+
447
+
448
+ @functools.lru_cache(maxsize=8192)
449
+ def measure_text(text: str, family=None, size: float = 14.0, weight="normal",
450
+ style="normal", letter_spacing: float = 0.0) -> float:
451
+ """Width in px of a single line of text."""
452
+ if not text:
453
+ return 0.0
454
+ return get_font(family, weight, style).string_width(text, size, letter_spacing)
455
+
456
+
457
+ def text_extents(text: str, family=None, size: float = 14.0, weight="normal",
458
+ style="normal", letter_spacing: float = 0.0) -> tuple:
459
+ """``(width, ascent, descent)`` in px for one line."""
460
+ f = get_font(family, weight, style)
461
+ return (f.string_width(text, size, letter_spacing),
462
+ f.metrics.ascent * size, f.metrics.descent * size)