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/__init__.py +114 -0
- figkit/audit.py +611 -0
- figkit/colors.py +304 -0
- figkit/component.py +110 -0
- figkit/components.py +727 -0
- figkit/connectors.py +689 -0
- figkit/core.py +1021 -0
- figkit/export.py +301 -0
- figkit/figure.py +312 -0
- figkit/fonts.py +462 -0
- figkit/frame.py +376 -0
- figkit/geom.py +497 -0
- figkit/image.py +308 -0
- figkit/layout.py +529 -0
- figkit/mathtext.py +320 -0
- figkit/paint.py +136 -0
- figkit/py.typed +0 -0
- figkit/shapes.py +764 -0
- figkit/style.py +502 -0
- figkit/svgdoc.py +133 -0
- figkit/svgpath.py +470 -0
- figkit/text.py +625 -0
- figkit/themes.py +163 -0
- figkit-0.1.0.dist-info/METADATA +277 -0
- figkit-0.1.0.dist-info/RECORD +28 -0
- figkit-0.1.0.dist-info/WHEEL +5 -0
- figkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- figkit-0.1.0.dist-info/top_level.txt +1 -0
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)
|