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/text.py
ADDED
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
"""Text layout: multi-line, inline ``$math$``, wrapping and optical centring."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import warnings
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
from .core import Element
|
|
10
|
+
from .fonts import get_font, measure_text
|
|
11
|
+
from .geom import BBox
|
|
12
|
+
from .mathtext import MathError, math_available, render_math
|
|
13
|
+
from .style import normalize_dash
|
|
14
|
+
from .svgdoc import Node, RenderContext
|
|
15
|
+
from .svgpath import translate_path_data
|
|
16
|
+
|
|
17
|
+
__all__ = ["Text", "Label", "Span", "TextLayout", "layout_text",
|
|
18
|
+
"measure_block"]
|
|
19
|
+
|
|
20
|
+
# ``$...$`` with support for escaped ``\$``
|
|
21
|
+
_MATH_RE = re.compile(r"(?<!\\)\$(.+?)(?<!\\)\$", re.S)
|
|
22
|
+
_BOLD_RE = re.compile(r"\*\*(.+?)\*\*", re.S)
|
|
23
|
+
_ITALIC_RE = re.compile(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", re.S)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Span:
|
|
28
|
+
"""A styled piece of a :class:`Text`.
|
|
29
|
+
|
|
30
|
+
>>> Text(["the model ", Span("fails", color="@bad", strike=True), " here"])
|
|
31
|
+
|
|
32
|
+
Anything left as ``None`` is inherited from the ``Text`` it belongs to.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
text: str
|
|
36
|
+
color: str = None
|
|
37
|
+
bold: bool = None
|
|
38
|
+
italic: bool = None
|
|
39
|
+
weight: str = None
|
|
40
|
+
style: str = None
|
|
41
|
+
size: float = None
|
|
42
|
+
family: str = None
|
|
43
|
+
strike: bool = False
|
|
44
|
+
underline: bool = False
|
|
45
|
+
|
|
46
|
+
def overrides(self) -> dict:
|
|
47
|
+
"""The properties this span changes, as a plain dict."""
|
|
48
|
+
out = {}
|
|
49
|
+
if self.color is not None:
|
|
50
|
+
out["color"] = self.color
|
|
51
|
+
if self.weight is not None:
|
|
52
|
+
out["weight"] = self.weight
|
|
53
|
+
elif self.bold is not None:
|
|
54
|
+
out["weight"] = "bold" if self.bold else "normal"
|
|
55
|
+
if self.style is not None:
|
|
56
|
+
out["style"] = self.style
|
|
57
|
+
elif self.italic is not None:
|
|
58
|
+
out["style"] = "italic" if self.italic else "normal"
|
|
59
|
+
if self.size is not None:
|
|
60
|
+
out["size"] = float(self.size)
|
|
61
|
+
if self.family is not None:
|
|
62
|
+
out["family"] = self.family
|
|
63
|
+
decoration = tuple(d for d, on in (("strike", self.strike),
|
|
64
|
+
("underline", self.underline)) if on)
|
|
65
|
+
if decoration:
|
|
66
|
+
out["decoration"] = decoration
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class Run:
|
|
72
|
+
"""One measured piece of a line: plain text or a math expression."""
|
|
73
|
+
|
|
74
|
+
kind: str # "text" | "math"
|
|
75
|
+
content: str
|
|
76
|
+
width: float = 0.0
|
|
77
|
+
ascent: float = 0.0
|
|
78
|
+
descent: float = 0.0
|
|
79
|
+
x: float = 0.0
|
|
80
|
+
font_size: float = 12.0
|
|
81
|
+
weight: str = "normal"
|
|
82
|
+
style: str = "normal"
|
|
83
|
+
math: object = None
|
|
84
|
+
color: str = None
|
|
85
|
+
family: object = None
|
|
86
|
+
decoration: tuple = ()
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def key(self) -> tuple:
|
|
90
|
+
"""What must match for two runs to be merged into one SVG node."""
|
|
91
|
+
return (self.kind, self.weight, self.style, self.font_size,
|
|
92
|
+
self.color, self.family, self.decoration)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class Line:
|
|
97
|
+
runs: list = field(default_factory=list)
|
|
98
|
+
width: float = 0.0
|
|
99
|
+
ascent: float = 0.0
|
|
100
|
+
descent: float = 0.0
|
|
101
|
+
baseline: float = 0.0 # relative to the top of the block
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class TextLayout:
|
|
106
|
+
"""The measured result of laying out a string."""
|
|
107
|
+
|
|
108
|
+
lines: list = field(default_factory=list)
|
|
109
|
+
width: float = 0.0
|
|
110
|
+
height: float = 0.0
|
|
111
|
+
cap_top: float = 0.0 # top of the cap-height band (optical centring)
|
|
112
|
+
baseline_last: float = 0.0
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def optical_height(self) -> float:
|
|
116
|
+
return max(0.0, self.baseline_last - self.cap_top)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _split_math(text: str) -> list:
|
|
120
|
+
"""Split a string into alternating text / math pieces."""
|
|
121
|
+
out = []
|
|
122
|
+
pos = 0
|
|
123
|
+
for m in _MATH_RE.finditer(text):
|
|
124
|
+
if m.start() > pos:
|
|
125
|
+
out.append(("text", text[pos:m.start()]))
|
|
126
|
+
out.append(("math", m.group(1)))
|
|
127
|
+
pos = m.end()
|
|
128
|
+
if pos < len(text):
|
|
129
|
+
out.append(("text", text[pos:]))
|
|
130
|
+
if not out:
|
|
131
|
+
out = [("text", text)]
|
|
132
|
+
return [(k, v.replace("\\$", "$") if k == "text" else v) for k, v in out]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _apply_markup(piece: str, weight: str, style: str) -> list:
|
|
136
|
+
"""Expand ``**bold**`` / ``*italic*`` into styled sub-runs."""
|
|
137
|
+
out = []
|
|
138
|
+
pos = 0
|
|
139
|
+
tokens = []
|
|
140
|
+
for m in _BOLD_RE.finditer(piece):
|
|
141
|
+
tokens.append((m.start(), m.end(), m.group(1), "bold", None))
|
|
142
|
+
for m in _ITALIC_RE.finditer(piece):
|
|
143
|
+
if any(s <= m.start() < e for s, e, *_ in tokens):
|
|
144
|
+
continue
|
|
145
|
+
tokens.append((m.start(), m.end(), m.group(1), None, "italic"))
|
|
146
|
+
tokens.sort()
|
|
147
|
+
for start, end, body, w, s in tokens:
|
|
148
|
+
if start > pos:
|
|
149
|
+
out.append((piece[pos:start], weight, style))
|
|
150
|
+
out.append((body, w or weight, s or style))
|
|
151
|
+
pos = end
|
|
152
|
+
if pos < len(piece):
|
|
153
|
+
out.append((piece[pos:], weight, style))
|
|
154
|
+
return out or [(piece, weight, style)]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _as_pieces(content, base: dict) -> list:
|
|
158
|
+
"""Flatten ``str`` / :class:`Span` / lists into ``[(text, overrides)]``."""
|
|
159
|
+
if content is None:
|
|
160
|
+
return [("", dict(base))]
|
|
161
|
+
if isinstance(content, Span):
|
|
162
|
+
return [(str(content.text), {**base, **content.overrides()})]
|
|
163
|
+
if isinstance(content, str):
|
|
164
|
+
return [(content, dict(base))]
|
|
165
|
+
if isinstance(content, (list, tuple)):
|
|
166
|
+
out = []
|
|
167
|
+
for item in content:
|
|
168
|
+
out.extend(_as_pieces(item, base))
|
|
169
|
+
return out
|
|
170
|
+
return [(str(content), dict(base))]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _split_lines(pieces: list) -> list:
|
|
174
|
+
"""Break styled pieces on newlines, keeping each piece's styling."""
|
|
175
|
+
lines = [[]]
|
|
176
|
+
for text, overrides in pieces:
|
|
177
|
+
parts = str(text).split("\n")
|
|
178
|
+
for i, part in enumerate(parts):
|
|
179
|
+
if i:
|
|
180
|
+
lines.append([])
|
|
181
|
+
if part:
|
|
182
|
+
lines[-1].append((part, overrides))
|
|
183
|
+
return lines
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def layout_text(text, font_family=None, font_size: float = 14.0,
|
|
187
|
+
weight="normal", style="normal", line_height: float = 1.3,
|
|
188
|
+
letter_spacing: float = 0.0, max_width: float = None,
|
|
189
|
+
math_backend: str = "auto", math_scale: float = 1.0,
|
|
190
|
+
markup: bool = False, color=None) -> TextLayout:
|
|
191
|
+
"""Measure ``text``, returning line boxes, run positions and baselines.
|
|
192
|
+
|
|
193
|
+
``text`` may be a string, a :class:`Span`, or a list mixing the two.
|
|
194
|
+
"""
|
|
195
|
+
base = {"weight": weight, "style": style, "size": float(font_size),
|
|
196
|
+
"family": font_family, "color": color, "decoration": ()}
|
|
197
|
+
metrics = get_font(font_family, weight, style).metrics
|
|
198
|
+
|
|
199
|
+
lines: list = []
|
|
200
|
+
for styled_line in _split_lines(_as_pieces(text, base)):
|
|
201
|
+
runs: list = []
|
|
202
|
+
for body, ov in styled_line:
|
|
203
|
+
runs.extend(_build_runs(body, ov, markup, math_backend,
|
|
204
|
+
math_scale, letter_spacing))
|
|
205
|
+
if max_width and max_width > 0:
|
|
206
|
+
lines.extend(_wrap(runs, max_width, letter_spacing))
|
|
207
|
+
else:
|
|
208
|
+
lines.append(runs)
|
|
209
|
+
|
|
210
|
+
out_lines: list = []
|
|
211
|
+
default_asc = metrics.ascent * font_size
|
|
212
|
+
default_desc = metrics.descent * font_size
|
|
213
|
+
for runs in lines:
|
|
214
|
+
x = 0.0
|
|
215
|
+
for r in runs:
|
|
216
|
+
r.x = x
|
|
217
|
+
x += r.width
|
|
218
|
+
asc = max([r.ascent for r in runs] + [default_asc])
|
|
219
|
+
desc = max([r.descent for r in runs] + [default_desc])
|
|
220
|
+
out_lines.append(Line(runs=runs, width=x, ascent=asc, descent=desc))
|
|
221
|
+
|
|
222
|
+
leading = font_size * line_height
|
|
223
|
+
y = 0.0
|
|
224
|
+
max_w = 0.0
|
|
225
|
+
for ln in out_lines:
|
|
226
|
+
box = max(leading, ln.ascent + ln.descent)
|
|
227
|
+
extra = max(0.0, box - (ln.ascent + ln.descent))
|
|
228
|
+
ln.baseline = y + extra / 2.0 + ln.ascent
|
|
229
|
+
y += box
|
|
230
|
+
max_w = max(max_w, ln.width)
|
|
231
|
+
|
|
232
|
+
# The cap band (first cap-height down to the last baseline) is what we
|
|
233
|
+
# centre on: it is what the eye reads as the extent of a short label.
|
|
234
|
+
cap = (metrics.cap_height or 0.7) * font_size
|
|
235
|
+
first = out_lines[0] if out_lines else Line(baseline=cap)
|
|
236
|
+
last = out_lines[-1] if out_lines else first
|
|
237
|
+
top_reach = cap
|
|
238
|
+
if first.runs and any(r.kind == "math" for r in first.runs):
|
|
239
|
+
top_reach = max(cap, first.ascent)
|
|
240
|
+
return TextLayout(lines=out_lines, width=max_w, height=y,
|
|
241
|
+
cap_top=first.baseline - top_reach,
|
|
242
|
+
baseline_last=last.baseline)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _build_runs(body: str, ov: dict, markup: bool, math_backend: str,
|
|
246
|
+
math_scale: float, letter_spacing: float) -> list:
|
|
247
|
+
"""Turn one styled string into measured runs, expanding $math$ and markup."""
|
|
248
|
+
size = ov["size"]
|
|
249
|
+
runs: list = []
|
|
250
|
+
for kind, content in _split_math(body):
|
|
251
|
+
if kind == "math":
|
|
252
|
+
if not content.strip():
|
|
253
|
+
continue
|
|
254
|
+
try:
|
|
255
|
+
mr = render_math(content, size * math_scale, math_backend)
|
|
256
|
+
except MathError:
|
|
257
|
+
# A genuine TeX error is the caller's problem, but simply not
|
|
258
|
+
# having the optional dependency should not blow up a whole
|
|
259
|
+
# figure — set the source in italics and say what is missing.
|
|
260
|
+
if math_available():
|
|
261
|
+
raise
|
|
262
|
+
warnings.warn(
|
|
263
|
+
"figkit: $math$ needs matplotlib; the expression was set "
|
|
264
|
+
"as plain text instead. Install it with "
|
|
265
|
+
"pip install 'figkit[latex]'.", stacklevel=4)
|
|
266
|
+
runs.append(_text_run(content, ov, ov["weight"], "italic",
|
|
267
|
+
letter_spacing))
|
|
268
|
+
continue
|
|
269
|
+
runs.append(Run("math", content, mr.width, mr.ascent, mr.descent,
|
|
270
|
+
font_size=size, math=mr, color=ov.get("color"),
|
|
271
|
+
decoration=ov.get("decoration", ())))
|
|
272
|
+
continue
|
|
273
|
+
if content == "":
|
|
274
|
+
continue
|
|
275
|
+
sub = (_apply_markup(content, ov["weight"], ov["style"]) if markup
|
|
276
|
+
else [(content, ov["weight"], ov["style"])])
|
|
277
|
+
for part, w, st in sub:
|
|
278
|
+
if part == "":
|
|
279
|
+
continue
|
|
280
|
+
runs.append(_text_run(part, ov, w, st, letter_spacing))
|
|
281
|
+
return runs
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _text_run(part: str, ov: dict, weight, style, letter_spacing: float) -> Run:
|
|
285
|
+
size = ov["size"]
|
|
286
|
+
family = ov.get("family")
|
|
287
|
+
width = measure_text(part, _fam(family), size, weight, style, letter_spacing)
|
|
288
|
+
metrics = get_font(family, weight, style).metrics
|
|
289
|
+
return Run("text", part, width, metrics.ascent * size,
|
|
290
|
+
metrics.descent * size, font_size=size, weight=weight,
|
|
291
|
+
style=style, color=ov.get("color"), family=family,
|
|
292
|
+
decoration=ov.get("decoration", ()))
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _merge_runs(runs: list) -> list:
|
|
296
|
+
"""Join neighbouring text runs that share styling.
|
|
297
|
+
|
|
298
|
+
Emitting one ``<text>`` per *word* would pin each word to an absolute x —
|
|
299
|
+
exact, but if the viewer lacks the declared font the substitute's wider
|
|
300
|
+
glyphs collide. One ``<text>`` per phrase lets the renderer space the words
|
|
301
|
+
itself, so a substituted font degrades gracefully instead.
|
|
302
|
+
"""
|
|
303
|
+
out: list = []
|
|
304
|
+
for run in runs:
|
|
305
|
+
prev = out[-1] if out else None
|
|
306
|
+
if (prev is not None and prev.kind == "text" and run.kind == "text"
|
|
307
|
+
and prev.key == run.key
|
|
308
|
+
and abs((prev.x + prev.width) - run.x) < 0.01):
|
|
309
|
+
out[-1] = Run("text", prev.content + run.content,
|
|
310
|
+
prev.width + run.width, max(prev.ascent, run.ascent),
|
|
311
|
+
max(prev.descent, run.descent), prev.x,
|
|
312
|
+
prev.font_size, prev.weight, prev.style,
|
|
313
|
+
color=prev.color, family=prev.family,
|
|
314
|
+
decoration=prev.decoration)
|
|
315
|
+
else:
|
|
316
|
+
out.append(run)
|
|
317
|
+
return out
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _decoration_nodes(run, x: float, baseline: float, color, alpha) -> list:
|
|
321
|
+
"""Draw strike-through / underline as geometry.
|
|
322
|
+
|
|
323
|
+
``text-decoration`` is widely ignored by SVG rasterisers and disappears
|
|
324
|
+
entirely once text is outlined, so figkit draws the rules itself.
|
|
325
|
+
"""
|
|
326
|
+
if not run.decoration:
|
|
327
|
+
return []
|
|
328
|
+
size = run.font_size or 12.0
|
|
329
|
+
thickness = max(0.8, size * 0.055)
|
|
330
|
+
out = []
|
|
331
|
+
for kind in run.decoration:
|
|
332
|
+
if kind == "strike":
|
|
333
|
+
y = baseline - size * 0.28
|
|
334
|
+
elif kind == "underline":
|
|
335
|
+
y = baseline + size * 0.12
|
|
336
|
+
else:
|
|
337
|
+
continue
|
|
338
|
+
out.append(Node("rect", x=x, y=y - thickness / 2.0, width=run.width,
|
|
339
|
+
height=thickness, fill=color, fill_opacity=alpha,
|
|
340
|
+
stroke="none"))
|
|
341
|
+
return out
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _norm_content(value):
|
|
345
|
+
"""Accept a string, a Span, or a list of them."""
|
|
346
|
+
if value is None:
|
|
347
|
+
return ""
|
|
348
|
+
if isinstance(value, (str, Span, list, tuple)):
|
|
349
|
+
return value
|
|
350
|
+
return str(value)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def plain_text(content) -> str:
|
|
354
|
+
"""The text of a span tree, with styling dropped."""
|
|
355
|
+
if content is None:
|
|
356
|
+
return ""
|
|
357
|
+
if isinstance(content, Span):
|
|
358
|
+
return str(content.text)
|
|
359
|
+
if isinstance(content, str):
|
|
360
|
+
return content
|
|
361
|
+
if isinstance(content, (list, tuple)):
|
|
362
|
+
return "".join(plain_text(item) for item in content)
|
|
363
|
+
return str(content)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _fam(font_family):
|
|
367
|
+
if font_family is None:
|
|
368
|
+
return None
|
|
369
|
+
return font_family if isinstance(font_family, str) else ", ".join(font_family)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _wrap(runs: list, max_width: float, letter_spacing: float) -> list:
|
|
373
|
+
"""Greedy word wrap. Math runs are unbreakable atoms; every run keeps its
|
|
374
|
+
own styling, so a wrapped line can still mix spans."""
|
|
375
|
+
lines: list = []
|
|
376
|
+
cur: list = []
|
|
377
|
+
cur_w = 0.0
|
|
378
|
+
|
|
379
|
+
def flush():
|
|
380
|
+
nonlocal cur, cur_w
|
|
381
|
+
if cur:
|
|
382
|
+
while cur and cur[-1].kind == "text" and cur[-1].content.strip() == "":
|
|
383
|
+
cur.pop()
|
|
384
|
+
lines.append(cur)
|
|
385
|
+
cur = []
|
|
386
|
+
cur_w = 0.0
|
|
387
|
+
|
|
388
|
+
def clone(run: Run, content: str, width: float) -> Run:
|
|
389
|
+
return Run(run.kind, content, width, run.ascent, run.descent,
|
|
390
|
+
font_size=run.font_size, weight=run.weight, style=run.style,
|
|
391
|
+
math=run.math, color=run.color, family=run.family,
|
|
392
|
+
decoration=run.decoration)
|
|
393
|
+
|
|
394
|
+
for run in runs:
|
|
395
|
+
if run.kind == "math":
|
|
396
|
+
if cur_w + run.width > max_width and cur:
|
|
397
|
+
flush()
|
|
398
|
+
cur.append(clone(run, run.content, run.width))
|
|
399
|
+
cur_w += run.width
|
|
400
|
+
continue
|
|
401
|
+
for word in re.split(r"(\s+)", run.content):
|
|
402
|
+
if word == "":
|
|
403
|
+
continue
|
|
404
|
+
w = measure_text(word, _fam(run.family), run.font_size, run.weight,
|
|
405
|
+
run.style, letter_spacing)
|
|
406
|
+
if word.strip() == "":
|
|
407
|
+
if cur:
|
|
408
|
+
cur.append(clone(run, word, w))
|
|
409
|
+
cur_w += w
|
|
410
|
+
continue
|
|
411
|
+
if cur_w + w > max_width and cur:
|
|
412
|
+
flush()
|
|
413
|
+
cur.append(clone(run, word, w))
|
|
414
|
+
cur_w += w
|
|
415
|
+
flush()
|
|
416
|
+
return lines or [[]]
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def measure_block(text: str, **kw) -> tuple:
|
|
420
|
+
"""``(width, height)`` of a text block — handy for sizing things by hand."""
|
|
421
|
+
lay = layout_text(text, **kw)
|
|
422
|
+
return lay.width, lay.height
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
# ==========================================================================
|
|
426
|
+
# Text element
|
|
427
|
+
# ==========================================================================
|
|
428
|
+
|
|
429
|
+
class Text(Element):
|
|
430
|
+
"""A block of text. Supports ``\\n``, inline ``$math$`` and wrapping.
|
|
431
|
+
|
|
432
|
+
>>> Text("Feature\\nExtractor", bold=True, font_size=16)
|
|
433
|
+
>>> Text("loss $L_{\\\\mathrm{fmap}}$", color="#333")
|
|
434
|
+
"""
|
|
435
|
+
|
|
436
|
+
role = "text"
|
|
437
|
+
ANCHOR_MAP = {"left": "start", "start": "start", "center": "middle",
|
|
438
|
+
"middle": "middle", "right": "end", "end": "end"}
|
|
439
|
+
|
|
440
|
+
def __init__(self, text="", x: float = 0.0, y: float = 0.0, *,
|
|
441
|
+
width: float = None, wrap: float = None, markup: bool = False,
|
|
442
|
+
rotate: float = None, **kw):
|
|
443
|
+
self._text = _norm_content(text)
|
|
444
|
+
self._wrap = wrap if wrap is not None else width
|
|
445
|
+
self._markup = markup
|
|
446
|
+
self._layout: TextLayout | None = None
|
|
447
|
+
super().__init__(x, y, None, None, **kw)
|
|
448
|
+
if self._wrap:
|
|
449
|
+
self._explicit_w = False
|
|
450
|
+
if rotate:
|
|
451
|
+
self.rotate(rotate)
|
|
452
|
+
|
|
453
|
+
# -- content ---------------------------------------------------------
|
|
454
|
+
@property
|
|
455
|
+
def text(self) -> str:
|
|
456
|
+
"""The plain-text content, with any span styling flattened away."""
|
|
457
|
+
return plain_text(self._text)
|
|
458
|
+
|
|
459
|
+
@property
|
|
460
|
+
def content(self):
|
|
461
|
+
"""The content as given: a string, a :class:`Span`, or a list."""
|
|
462
|
+
return self._text
|
|
463
|
+
|
|
464
|
+
@text.setter
|
|
465
|
+
def text(self, value) -> None:
|
|
466
|
+
self._text = _norm_content(value)
|
|
467
|
+
self.invalidate()
|
|
468
|
+
|
|
469
|
+
def set_text(self, value: str) -> "Text":
|
|
470
|
+
self.text = value
|
|
471
|
+
return self
|
|
472
|
+
|
|
473
|
+
def wrap_at(self, width: float) -> "Text":
|
|
474
|
+
self._wrap = width
|
|
475
|
+
return self.invalidate()
|
|
476
|
+
|
|
477
|
+
# -- measurement -----------------------------------------------------
|
|
478
|
+
def _layout_kwargs(self) -> dict:
|
|
479
|
+
return dict(
|
|
480
|
+
font_family=self.prop("font_family"),
|
|
481
|
+
font_size=float(self.prop("font_size")),
|
|
482
|
+
weight=self.prop("font_weight"),
|
|
483
|
+
style=self.prop("font_style"),
|
|
484
|
+
line_height=float(self.prop("line_height")),
|
|
485
|
+
letter_spacing=float(self.prop("letter_spacing", 0) or 0),
|
|
486
|
+
max_width=self._wrap,
|
|
487
|
+
math_backend=self.prop("math_backend", "auto"),
|
|
488
|
+
math_scale=float(self.prop("math_scale", 1.0) or 1.0),
|
|
489
|
+
markup=self._markup,
|
|
490
|
+
color=self.prop("color"),
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
@property
|
|
494
|
+
def layout(self) -> TextLayout:
|
|
495
|
+
self._ensure()
|
|
496
|
+
return self._layout
|
|
497
|
+
|
|
498
|
+
def _measure(self) -> None:
|
|
499
|
+
self._layout = layout_text(self._text, **self._layout_kwargs())
|
|
500
|
+
self._w = self._wrap if self._wrap else self._layout.width
|
|
501
|
+
self._h = self._layout.height
|
|
502
|
+
|
|
503
|
+
@property
|
|
504
|
+
def optical_bbox(self) -> BBox:
|
|
505
|
+
"""Cap-height band — what you want to centre inside a box."""
|
|
506
|
+
self._ensure()
|
|
507
|
+
bb = self.bbox
|
|
508
|
+
lay = self._layout
|
|
509
|
+
if not lay.lines:
|
|
510
|
+
return bb
|
|
511
|
+
top = bb.y + lay.cap_top
|
|
512
|
+
bottom = bb.y + lay.baseline_last
|
|
513
|
+
return BBox(bb.x, top, bb.w, max(0.0, bottom - top))
|
|
514
|
+
|
|
515
|
+
@property
|
|
516
|
+
def first_baseline(self) -> float:
|
|
517
|
+
self._ensure()
|
|
518
|
+
return self.bbox.y + (self._layout.lines[0].baseline
|
|
519
|
+
if self._layout.lines else 0.0)
|
|
520
|
+
|
|
521
|
+
# -- rendering -------------------------------------------------------
|
|
522
|
+
def _render_content(self, ctx: RenderContext):
|
|
523
|
+
self._ensure()
|
|
524
|
+
lay = self._layout
|
|
525
|
+
if not lay.lines:
|
|
526
|
+
return None
|
|
527
|
+
bb = self.local_bbox
|
|
528
|
+
from .paint import _color as _split_color
|
|
529
|
+
color, color_alpha = _split_color(self.prop("color"), ctx)
|
|
530
|
+
if color is None:
|
|
531
|
+
color, color_alpha = "#000000", None
|
|
532
|
+
align = str(self.prop("text_align", "center")).lower()
|
|
533
|
+
anchor = self.ANCHOR_MAP.get(align, "middle")
|
|
534
|
+
family = self.prop("font_family")
|
|
535
|
+
size = float(self.prop("font_size"))
|
|
536
|
+
weight = self.prop("font_weight")
|
|
537
|
+
fstyle = self.prop("font_style")
|
|
538
|
+
spacing = float(self.prop("letter_spacing", 0) or 0)
|
|
539
|
+
decoration = self.prop("text_decoration", None)
|
|
540
|
+
stroke = self.prop("stroke", None)
|
|
541
|
+
stroke_w = self.prop("stroke_width", 0)
|
|
542
|
+
math_color = self.prop("math_color", None) or color
|
|
543
|
+
as_paths = ctx.text_as_paths
|
|
544
|
+
ctx.note_font(family, weight, fstyle)
|
|
545
|
+
|
|
546
|
+
nodes: list = []
|
|
547
|
+
for line in lay.lines:
|
|
548
|
+
if not line.runs:
|
|
549
|
+
continue
|
|
550
|
+
runs = _merge_runs(line.runs)
|
|
551
|
+
if anchor == "start":
|
|
552
|
+
x0 = bb.x
|
|
553
|
+
elif anchor == "end":
|
|
554
|
+
x0 = bb.x + bb.w - line.width
|
|
555
|
+
else:
|
|
556
|
+
x0 = bb.x + (bb.w - line.width) / 2.0
|
|
557
|
+
by = bb.y + line.baseline
|
|
558
|
+
for run in runs:
|
|
559
|
+
rx = x0 + run.x
|
|
560
|
+
run_color, run_alpha = (_split_color(run.color, ctx)
|
|
561
|
+
if run.color else (color, color_alpha))
|
|
562
|
+
if run.kind == "math":
|
|
563
|
+
mr = run.math
|
|
564
|
+
if mr is None or mr.empty:
|
|
565
|
+
continue
|
|
566
|
+
d = translate_path_data(mr.d, rx, by)
|
|
567
|
+
nodes.append(Node("path", d=d,
|
|
568
|
+
fill=run_color or math_color,
|
|
569
|
+
fill_opacity=run_alpha,
|
|
570
|
+
fill_rule=mr.fill_rule, stroke="none"))
|
|
571
|
+
nodes.extend(_decoration_nodes(run, rx, by, run_color,
|
|
572
|
+
run_alpha))
|
|
573
|
+
continue
|
|
574
|
+
if not run.content.strip():
|
|
575
|
+
continue
|
|
576
|
+
run_family = run.family if run.family is not None else family
|
|
577
|
+
run_size = run.font_size or size
|
|
578
|
+
if as_paths:
|
|
579
|
+
font = get_font(run_family, run.weight, run.style)
|
|
580
|
+
if font.available:
|
|
581
|
+
d = font.text_to_path(run.content, run_size, spacing)
|
|
582
|
+
if d:
|
|
583
|
+
nodes.append(Node("path",
|
|
584
|
+
d=translate_path_data(d, rx, by),
|
|
585
|
+
fill=run_color,
|
|
586
|
+
fill_opacity=run_alpha,
|
|
587
|
+
stroke="none"))
|
|
588
|
+
nodes.extend(_decoration_nodes(run, rx, by,
|
|
589
|
+
run_color, run_alpha))
|
|
590
|
+
continue
|
|
591
|
+
ctx.warn(f"text_as_paths: no outline font for {run_family!r}")
|
|
592
|
+
attrs = dict(x=rx, y=by, fill=run_color, fill_opacity=run_alpha,
|
|
593
|
+
font_family=run_family, font_size=run_size)
|
|
594
|
+
if str(run.weight) not in ("normal", "400"):
|
|
595
|
+
attrs["font_weight"] = run.weight
|
|
596
|
+
if str(run.style) != "normal":
|
|
597
|
+
attrs["font_style"] = run.style
|
|
598
|
+
if spacing:
|
|
599
|
+
attrs["letter_spacing"] = spacing
|
|
600
|
+
if decoration:
|
|
601
|
+
attrs["text_decoration"] = decoration
|
|
602
|
+
if stroke and stroke != "none" and stroke_w:
|
|
603
|
+
attrs["stroke"] = stroke
|
|
604
|
+
attrs["stroke_width"] = stroke_w
|
|
605
|
+
attrs["paint_order"] = "stroke"
|
|
606
|
+
dash = normalize_dash(self.prop("stroke_dash", None))
|
|
607
|
+
if dash:
|
|
608
|
+
attrs["stroke_dasharray"] = dash
|
|
609
|
+
attrs["xml__space"] = ("preserve"
|
|
610
|
+
if run.content != run.content.strip()
|
|
611
|
+
else None)
|
|
612
|
+
nodes.append(Node("text", text=run.content, **attrs))
|
|
613
|
+
nodes.extend(_decoration_nodes(run, rx, by, run_color,
|
|
614
|
+
run_alpha))
|
|
615
|
+
return nodes or None
|
|
616
|
+
|
|
617
|
+
def __repr__(self) -> str:
|
|
618
|
+
preview = self._text if len(self._text) <= 24 else self._text[:21] + "..."
|
|
619
|
+
return f"<Text {preview!r} {self.bbox}>"
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
class Label(Text):
|
|
623
|
+
"""Text with the ``label`` role — smaller by default, for annotations."""
|
|
624
|
+
|
|
625
|
+
role = "label"
|