codefig 0.17.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.
- codefig/__init__.py +210 -0
- codefig/_validate.py +12 -0
- codefig/autowrap.py +277 -0
- codefig/charts.py +1806 -0
- codefig/code.py +602 -0
- codefig/compose.py +374 -0
- codefig/constraints.py +1571 -0
- codefig/dim.py +214 -0
- codefig/edge_align.py +231 -0
- codefig/edges.py +2824 -0
- codefig/export.py +204 -0
- codefig/figure.py +1923 -0
- codefig/fills.py +146 -0
- codefig/flows.py +102 -0
- codefig/glyphs.py +395 -0
- codefig/icon.py +288 -0
- codefig/icons/LICENSE-tabler-icons.txt +21 -0
- codefig/icons/__init__.py +101 -0
- codefig/icons/tabler.json +1 -0
- codefig/legend.py +483 -0
- codefig/lint.py +1737 -0
- codefig/lint_geom.py +528 -0
- codefig/node.py +864 -0
- codefig/notices.py +34 -0
- codefig/overlaps.py +392 -0
- codefig/policy.py +137 -0
- codefig/primitives.py +2721 -0
- codefig/py.typed +0 -0
- codefig/regions.py +727 -0
- codefig/render_svg.py +512 -0
- codefig/routing.py +932 -0
- codefig/shapes.py +642 -0
- codefig/solids.py +514 -0
- codefig/steps.py +203 -0
- codefig/styles.py +893 -0
- codefig/textutil.py +889 -0
- codefig/timeline.py +1288 -0
- codefig/tree.py +277 -0
- codefig/venues.py +93 -0
- codefig-0.17.0.dist-info/METADATA +542 -0
- codefig-0.17.0.dist-info/RECORD +44 -0
- codefig-0.17.0.dist-info/WHEEL +5 -0
- codefig-0.17.0.dist-info/licenses/LICENSE +21 -0
- codefig-0.17.0.dist-info/top_level.txt +1 -0
codefig/textutil.py
ADDED
|
@@ -0,0 +1,889 @@
|
|
|
1
|
+
"""Text helpers: math markup, CJK-aware measure, XML escape."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from typing import List, Optional, Sequence, Tuple
|
|
8
|
+
|
|
9
|
+
# Approximate fallbacks when no font file is available
|
|
10
|
+
_AVG_RATIO = 0.56
|
|
11
|
+
_MONO_RATIO = 0.60
|
|
12
|
+
_CJK_RATIO = 1.0 # full em for CJK ideographs in heuristic mode
|
|
13
|
+
|
|
14
|
+
# Unicode subscript / superscript maps (used when collapsing to plain string)
|
|
15
|
+
_SUB_MAP = str.maketrans(
|
|
16
|
+
{
|
|
17
|
+
"0": "₀", "1": "₁", "2": "₂", "3": "₃", "4": "₄",
|
|
18
|
+
"5": "₅", "6": "₆", "7": "₇", "8": "₈", "9": "₉",
|
|
19
|
+
"+": "₊", "-": "₋", "=": "₌", "(": "₍", ")": "₎",
|
|
20
|
+
"a": "ₐ", "e": "ₑ", "h": "ₕ", "i": "ᵢ", "j": "ⱼ",
|
|
21
|
+
"k": "ₖ", "l": "ₗ", "m": "ₘ", "n": "ₙ", "o": "ₒ",
|
|
22
|
+
"p": "ₚ", "r": "ᵣ", "s": "ₛ", "t": "ₜ", "u": "ᵤ",
|
|
23
|
+
"v": "ᵥ", "x": "ₓ",
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
_SUPER_MAP = str.maketrans(
|
|
27
|
+
{
|
|
28
|
+
"0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴",
|
|
29
|
+
"5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹",
|
|
30
|
+
"+": "⁺", "-": "⁻", "=": "⁼", "(": "⁽", ")": "⁾",
|
|
31
|
+
"i": "ⁱ", "n": "ⁿ",
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# q_{i1}, z_{ii}, x^{2}, a_i (single token), also ^{...}
|
|
36
|
+
_MATH_TOKEN_RE = re.compile(
|
|
37
|
+
r"(\^\{([^}]+)\}|\^([A-Za-z0-9]+|[+\-−∗′″†‡])|_\{([^}]+)\}|_([A-Za-z0-9]+|[+\-−])|[^_^\n]+|\n)"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# CJK Unified Ideographs + Ext A/B common + fullwidth + kana/hangul
|
|
41
|
+
_CJK_RE = re.compile(
|
|
42
|
+
r"[\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u3400-\u4DBF"
|
|
43
|
+
r"\u4E00-\u9FFF\uF900-\uFAFF\uFF00-\uFFEF"
|
|
44
|
+
r"\U00020000-\U0002A6DF\uAC00-\uD7AF]"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
MathSeg = Tuple[str, str] # (kind, text) kind in text|sub|super|nl
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def contains_cjk(text: str) -> bool:
|
|
51
|
+
return bool(_CJK_RE.search(text))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _is_cjk_char(ch: str) -> bool:
|
|
55
|
+
return bool(_CJK_RE.match(ch))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Symbols (arrows, math operators, enclosed alphanumerics ①, shapes, dingbats)
|
|
59
|
+
# that serif text faces usually lack; under the serif typeface they are routed
|
|
60
|
+
# to the sans face (DejaVu Sans) for both measurement and SVG output.
|
|
61
|
+
_SYM_RE = re.compile(r"[\u2190-\u2BFF]")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_glyph_cache: dict = {}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _lacks_glyph(ch: str) -> bool:
|
|
68
|
+
"""True when the theme's resolved sans face (e.g. Liberation Sans for the paper theme)
|
|
69
|
+
has no glyph for ``ch`` — such characters (⋯, ⋮, ⊕ …) fall back to DejaVu Sans for
|
|
70
|
+
both measurement and SVG output instead of rendering as a missing-glyph box."""
|
|
71
|
+
if ord(ch) < 0x0370 or _TYPEFACE[-1] != "sans":
|
|
72
|
+
return False
|
|
73
|
+
stack = _active_stack()
|
|
74
|
+
if not stack:
|
|
75
|
+
return False
|
|
76
|
+
key = (stack, ch)
|
|
77
|
+
hit = _glyph_cache.get(key)
|
|
78
|
+
if hit is not None:
|
|
79
|
+
return hit
|
|
80
|
+
lacks = False
|
|
81
|
+
r = resolve_font_stack(stack)
|
|
82
|
+
if r is not None:
|
|
83
|
+
f = _latin_font(False, False)
|
|
84
|
+
if f is not None:
|
|
85
|
+
try:
|
|
86
|
+
def sig(c):
|
|
87
|
+
m = f.getmask(c)
|
|
88
|
+
return m.size, bytes(m)
|
|
89
|
+
lacks = sig(ch) == sig("\U0010FFFD")
|
|
90
|
+
except Exception:
|
|
91
|
+
lacks = False
|
|
92
|
+
_glyph_cache[key] = lacks
|
|
93
|
+
return lacks
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _char_script(ch: str) -> str:
|
|
97
|
+
if _is_cjk_char(ch):
|
|
98
|
+
return "cjk"
|
|
99
|
+
if _TYPEFACE[-1] == "serif" and _SYM_RE.match(ch):
|
|
100
|
+
return "sym"
|
|
101
|
+
if _lacks_glyph(ch):
|
|
102
|
+
return "sym"
|
|
103
|
+
return "latin"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def needs_script_split(text: str) -> bool:
|
|
107
|
+
"""True when ``text`` mixes faces (CJK, symbols under the serif typeface, or glyphs
|
|
108
|
+
the theme's sans face lacks)."""
|
|
109
|
+
if contains_cjk(text):
|
|
110
|
+
return True
|
|
111
|
+
if _TYPEFACE[-1] == "serif":
|
|
112
|
+
return bool(_SYM_RE.search(text))
|
|
113
|
+
return any(_lacks_glyph(c) for c in text if ord(c) >= 0x0370)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def script_runs(text: str) -> List[Tuple[str, str]]:
|
|
117
|
+
"""
|
|
118
|
+
Split into contiguous (script, substring) runs.
|
|
119
|
+
|
|
120
|
+
script is "cjk", "sym" (symbols under the serif typeface) or "latin"
|
|
121
|
+
(everything else, including digits/punct). Empty text → [].
|
|
122
|
+
"""
|
|
123
|
+
if not text:
|
|
124
|
+
return []
|
|
125
|
+
runs: List[Tuple[str, str]] = []
|
|
126
|
+
cur_script = _char_script(text[0])
|
|
127
|
+
buf = [text[0]]
|
|
128
|
+
for ch in text[1:]:
|
|
129
|
+
sc = _char_script(ch)
|
|
130
|
+
if sc == cur_script:
|
|
131
|
+
buf.append(ch)
|
|
132
|
+
else:
|
|
133
|
+
runs.append((cur_script, "".join(buf)))
|
|
134
|
+
cur_script = sc
|
|
135
|
+
buf = [ch]
|
|
136
|
+
runs.append((cur_script, "".join(buf)))
|
|
137
|
+
return runs
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---- typeface (sans | serif) ------------------------------------------------
|
|
141
|
+
# The active typeface drives both measurement (Pillow face) and the CSS family
|
|
142
|
+
# emitted into SVG, so layout and rendering always agree. Figure enters
|
|
143
|
+
# ``use_typeface(theme.typeface)`` around layout + render.
|
|
144
|
+
TYPEFACES = ("sans", "serif")
|
|
145
|
+
_TYPEFACE = ["sans"]
|
|
146
|
+
|
|
147
|
+
_SERIF_FILES = {
|
|
148
|
+
False: ("NimbusRoman-Regular.otf", "DejaVuSerif.ttf", "Times New Roman.ttf"),
|
|
149
|
+
True: ("NimbusRoman-Bold.otf", "DejaVuSerif-Bold.ttf", "Times New Roman Bold.ttf"),
|
|
150
|
+
(False, True): ("NimbusRoman-Italic.otf", "DejaVuSerif-Italic.ttf", "Times New Roman Italic.ttf"),
|
|
151
|
+
(True, True): ("NimbusRoman-BoldItalic.otf", "DejaVuSerif-BoldItalic.ttf",
|
|
152
|
+
"Times New Roman Bold Italic.ttf"),
|
|
153
|
+
}
|
|
154
|
+
_SERIF_DIRS = (
|
|
155
|
+
"/usr/share/fonts/opentype/urw-base35",
|
|
156
|
+
"/usr/share/fonts/truetype/dejavu",
|
|
157
|
+
"/usr/share/fonts/dejavu",
|
|
158
|
+
"/Library/Fonts",
|
|
159
|
+
"/System/Library/Fonts/Supplemental",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def active_typeface() -> str:
|
|
164
|
+
return _TYPEFACE[-1]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class use_typeface:
|
|
168
|
+
"""Context manager: ``with use_typeface("serif"): ...`` (nests; restores on exit)."""
|
|
169
|
+
|
|
170
|
+
def __init__(self, name: str = "sans") -> None:
|
|
171
|
+
name = str(name or "sans").lower()
|
|
172
|
+
if name not in TYPEFACES:
|
|
173
|
+
raise ValueError(f"typeface must be one of {TYPEFACES}, got {name!r}")
|
|
174
|
+
self.name = name
|
|
175
|
+
|
|
176
|
+
def __enter__(self):
|
|
177
|
+
_TYPEFACE.append(self.name)
|
|
178
|
+
return self
|
|
179
|
+
|
|
180
|
+
def __exit__(self, *exc):
|
|
181
|
+
_TYPEFACE.pop()
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ---- theme font stack (0.6.0) ---------------------------------------------------
|
|
186
|
+
# ``Theme.font_stack`` lists preferred sans families; the first one installed is used
|
|
187
|
+
# for measurement (Pillow) and written *first* into SVG font-family (cairosvg only
|
|
188
|
+
# honours the first family), so measured and rendered glyphs always agree.
|
|
189
|
+
_FONT_EXTS = (".ttf", ".otf", ".ttc")
|
|
190
|
+
_fc_index: Optional[dict] = None
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _style_key(style: str) -> Optional[Tuple[bool, bool]]:
|
|
194
|
+
st = style.lower()
|
|
195
|
+
bold = "bold" in st
|
|
196
|
+
italic = "italic" in st or "oblique" in st
|
|
197
|
+
if any(k in st for k in ("light", "thin", "medium", "black", "condensed", "narrow",
|
|
198
|
+
"semi", "demi", "extra", "heavy")):
|
|
199
|
+
return None
|
|
200
|
+
return bold, italic
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _font_index() -> dict:
|
|
204
|
+
"""family → {(bold, italic): path} for installed TrueType/OpenType fonts."""
|
|
205
|
+
global _fc_index
|
|
206
|
+
if _fc_index is not None:
|
|
207
|
+
return _fc_index
|
|
208
|
+
idx: dict = {}
|
|
209
|
+
try:
|
|
210
|
+
import subprocess
|
|
211
|
+
|
|
212
|
+
out = subprocess.run(["fc-list", ":", "family", "style", "file"], capture_output=True,
|
|
213
|
+
text=True, timeout=10).stdout
|
|
214
|
+
except Exception:
|
|
215
|
+
out = ""
|
|
216
|
+
for line in out.splitlines():
|
|
217
|
+
try:
|
|
218
|
+
path, rest = line.split(": ", 1)
|
|
219
|
+
except ValueError:
|
|
220
|
+
continue
|
|
221
|
+
if not path.lower().endswith(_FONT_EXTS):
|
|
222
|
+
continue
|
|
223
|
+
fams, _, style = rest.partition(":style=")
|
|
224
|
+
key = _style_key(style.split(",")[0])
|
|
225
|
+
if key is None:
|
|
226
|
+
continue
|
|
227
|
+
for fam in fams.split(","):
|
|
228
|
+
idx.setdefault(fam.strip(), {}).setdefault(key, path.strip())
|
|
229
|
+
# no fontconfig (e.g. macOS without fc-list): probe well-known files
|
|
230
|
+
known = {
|
|
231
|
+
"Liberation Sans": ("/usr/share/fonts/truetype/liberation/LiberationSans-%s.ttf",
|
|
232
|
+
{(False, False): "Regular", (True, False): "Bold",
|
|
233
|
+
(False, True): "Italic", (True, True): "BoldItalic"}),
|
|
234
|
+
"Arial": ("/Library/Fonts/Arial%s.ttf", {(False, False): "", (True, False): " Bold",
|
|
235
|
+
(False, True): " Italic",
|
|
236
|
+
(True, True): " Bold Italic"}),
|
|
237
|
+
}
|
|
238
|
+
for fam, (pat, styles) in known.items():
|
|
239
|
+
if fam in idx:
|
|
240
|
+
continue
|
|
241
|
+
for key, suf in styles.items():
|
|
242
|
+
path = pat % suf
|
|
243
|
+
if os.path.exists(path):
|
|
244
|
+
idx.setdefault(fam, {})[key] = path
|
|
245
|
+
_fc_index = idx
|
|
246
|
+
return idx
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def resolve_font_stack(stack: Sequence[str]) -> Optional[Tuple[str, dict]]:
|
|
250
|
+
"""First installed family of ``stack`` → (family, {(bold, italic): path})."""
|
|
251
|
+
if not stack:
|
|
252
|
+
return None
|
|
253
|
+
idx = _font_index()
|
|
254
|
+
for fam in stack:
|
|
255
|
+
files = idx.get(fam)
|
|
256
|
+
if files and (False, False) in files:
|
|
257
|
+
return fam, files
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _active_stack() -> Tuple[str, ...]:
|
|
262
|
+
from .styles import current_theme
|
|
263
|
+
|
|
264
|
+
return tuple(current_theme().font_stack)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def resolved_sans_family() -> Optional[str]:
|
|
268
|
+
"""The sans family actually used under the active theme (None → legacy DejaVu)."""
|
|
269
|
+
r = resolve_font_stack(_active_stack())
|
|
270
|
+
return r[0] if r else None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def latin_font_family_css(mono: bool = False) -> str:
|
|
274
|
+
if mono:
|
|
275
|
+
return "'DejaVu Sans Mono', Menlo, Consolas, monospace"
|
|
276
|
+
if active_typeface() == "serif":
|
|
277
|
+
return "'Nimbus Roman', 'Times New Roman', Times, 'DejaVu Serif', serif"
|
|
278
|
+
stack = _active_stack()
|
|
279
|
+
r = resolve_font_stack(stack)
|
|
280
|
+
if r is not None:
|
|
281
|
+
rest = [f for f in stack if f != r[0]]
|
|
282
|
+
return ", ".join([f"'{r[0]}'"] + [f"'{f}'" if " " in f else f for f in rest]
|
|
283
|
+
+ ["sans-serif"])
|
|
284
|
+
return "'DejaVu Sans', Helvetica, Arial, sans-serif"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
_US = "\uE05F" # placeholder for a literal underscore while tokenising math markup
|
|
288
|
+
_IDENT_US = re.compile(r"(?<=[A-Za-z]{3})_(?=[A-Za-z0-9])")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _protect_identifier_underscores(text: str) -> str:
|
|
292
|
+
text = text.replace("\\_", _US)
|
|
293
|
+
return _IDENT_US.sub(_US, text)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def parse_math_markup(text: str) -> List[MathSeg]:
|
|
297
|
+
"""
|
|
298
|
+
Parse simple math markup into segments.
|
|
299
|
+
|
|
300
|
+
Supports: `q_{i}`, `z_{i1}`, `a_ii`, `x^{2}`, `x^2`, and (v0.13.0) one sign or
|
|
301
|
+
prime without braces: `H_1^+`, `x^′`, `P^∗`, `y_-` (any Unicode inside braces).
|
|
302
|
+
Newlines become ('nl', '').
|
|
303
|
+
|
|
304
|
+
Identifiers stay literal: an underscore after a word of three or more letters
|
|
305
|
+
(``same_y``, ``kv_cache``, ``lint_ignore``) is not a subscript, nor is ``\\_``.
|
|
306
|
+
Braces always subscript (``attn_{i}``).
|
|
307
|
+
"""
|
|
308
|
+
if not text:
|
|
309
|
+
return []
|
|
310
|
+
text = _protect_identifier_underscores(text)
|
|
311
|
+
segs: List[MathSeg] = []
|
|
312
|
+
for m in _MATH_TOKEN_RE.finditer(text):
|
|
313
|
+
tok = m.group(0).replace(_US, "_")
|
|
314
|
+
if tok == "\n":
|
|
315
|
+
segs.append(("nl", ""))
|
|
316
|
+
elif m.group(2) is not None:
|
|
317
|
+
segs.append(("super", m.group(2)))
|
|
318
|
+
elif m.group(3) is not None:
|
|
319
|
+
segs.append(("super", m.group(3)))
|
|
320
|
+
elif m.group(4) is not None:
|
|
321
|
+
segs.append(("sub", m.group(4)))
|
|
322
|
+
elif m.group(5) is not None:
|
|
323
|
+
segs.append(("sub", m.group(5)))
|
|
324
|
+
else:
|
|
325
|
+
segs.append(("text", tok))
|
|
326
|
+
return segs
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def expand_subscripts(text: str) -> str:
|
|
330
|
+
"""Collapse markup to a plain unicode string (for measure fallback / plain SVG)."""
|
|
331
|
+
parts: List[str] = []
|
|
332
|
+
for kind, body in parse_math_markup(text):
|
|
333
|
+
if kind == "nl":
|
|
334
|
+
parts.append("\n")
|
|
335
|
+
elif kind == "sub":
|
|
336
|
+
parts.append(body.translate(_SUB_MAP))
|
|
337
|
+
elif kind == "super":
|
|
338
|
+
parts.append(body.translate(_SUPER_MAP))
|
|
339
|
+
else:
|
|
340
|
+
parts.append(body)
|
|
341
|
+
return "".join(parts)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def has_math_markup(text: str) -> bool:
|
|
345
|
+
return bool(re.search(r"[_^]", text))
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# ---- fonts -----------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
_LATIN_FILES = {
|
|
351
|
+
(False, False): "DejaVuSans.ttf",
|
|
352
|
+
(False, True): "DejaVuSans-Bold.ttf",
|
|
353
|
+
(True, False): "DejaVuSansMono.ttf",
|
|
354
|
+
(True, True): "DejaVuSansMono-Bold.ttf",
|
|
355
|
+
(False, False, True): "DejaVuSans-Oblique.ttf",
|
|
356
|
+
(False, True, True): "DejaVuSans-BoldOblique.ttf",
|
|
357
|
+
(True, False, True): "DejaVuSansMono-Oblique.ttf",
|
|
358
|
+
(True, True, True): "DejaVuSansMono-BoldOblique.ttf",
|
|
359
|
+
}
|
|
360
|
+
_LATIN_DIRS = (
|
|
361
|
+
"/usr/share/fonts/truetype/dejavu",
|
|
362
|
+
"/usr/share/fonts/dejavu",
|
|
363
|
+
"/Library/Fonts",
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
# (path, face_index) for SC Regular / Bold
|
|
367
|
+
_CJK_CANDIDATES = (
|
|
368
|
+
("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", 2), # SC
|
|
369
|
+
("/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc", 2),
|
|
370
|
+
("/usr/share/fonts/opentype/noto/NotoSerifCJK-Regular.ttc", 2),
|
|
371
|
+
)
|
|
372
|
+
_CJK_BOLD_CANDIDATES = (
|
|
373
|
+
("/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc", 2),
|
|
374
|
+
("/usr/share/fonts/opentype/noto/NotoSerifCJK-Bold.ttc", 2),
|
|
375
|
+
("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", 2),
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
_font_cache: dict = {}
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _load_truetype(path: str, size: int, index: int = 0):
|
|
382
|
+
from PIL import ImageFont
|
|
383
|
+
|
|
384
|
+
try:
|
|
385
|
+
return ImageFont.truetype(path, size, index=index)
|
|
386
|
+
except TypeError:
|
|
387
|
+
# older Pillow without index=
|
|
388
|
+
return ImageFont.truetype(path, size)
|
|
389
|
+
except Exception:
|
|
390
|
+
return None
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _latin_font(mono: bool, bold: bool, italic: bool = False):
|
|
394
|
+
face = "sans" if mono else active_typeface()
|
|
395
|
+
stack = _active_stack() if (face == "sans" and not mono) else ()
|
|
396
|
+
key = ("latin", mono, bold, face, italic, stack)
|
|
397
|
+
if key in _font_cache:
|
|
398
|
+
return _font_cache[key]
|
|
399
|
+
f = None
|
|
400
|
+
if stack:
|
|
401
|
+
r = resolve_font_stack(stack)
|
|
402
|
+
if r is not None:
|
|
403
|
+
files = r[1]
|
|
404
|
+
path = files.get((bold, italic)) or files.get((bold, False)) or files[(False, False)]
|
|
405
|
+
f = _load_truetype(path, 100, 0)
|
|
406
|
+
if f is not None:
|
|
407
|
+
_font_cache[key] = f
|
|
408
|
+
return f
|
|
409
|
+
if face == "serif":
|
|
410
|
+
names = _SERIF_FILES[(bold, True)] if italic else _SERIF_FILES[bold]
|
|
411
|
+
for name in names:
|
|
412
|
+
for d in _SERIF_DIRS:
|
|
413
|
+
path = os.path.join(d, name)
|
|
414
|
+
if os.path.exists(path):
|
|
415
|
+
f = _load_truetype(path, 100, 0)
|
|
416
|
+
if f is not None:
|
|
417
|
+
break
|
|
418
|
+
if f is not None:
|
|
419
|
+
break
|
|
420
|
+
if f is None and italic:
|
|
421
|
+
f = _latin_font(mono, bold, False)
|
|
422
|
+
_font_cache[key] = f
|
|
423
|
+
return f
|
|
424
|
+
try:
|
|
425
|
+
fname = _LATIN_FILES[(mono, bold, True)] if italic else _LATIN_FILES[(mono, bold)]
|
|
426
|
+
for d in _LATIN_DIRS:
|
|
427
|
+
path = os.path.join(d, fname)
|
|
428
|
+
if os.path.exists(path):
|
|
429
|
+
f = _load_truetype(path, 100, 0)
|
|
430
|
+
break
|
|
431
|
+
except Exception:
|
|
432
|
+
f = None
|
|
433
|
+
if f is None and italic:
|
|
434
|
+
f = _latin_font(mono, bold, False)
|
|
435
|
+
_font_cache[key] = f
|
|
436
|
+
return f
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _cjk_font(bold: bool = False):
|
|
440
|
+
key = ("cjk", bold)
|
|
441
|
+
if key in _font_cache:
|
|
442
|
+
return _font_cache[key]
|
|
443
|
+
f = None
|
|
444
|
+
cands = _CJK_BOLD_CANDIDATES if bold else _CJK_CANDIDATES
|
|
445
|
+
for path, idx in cands:
|
|
446
|
+
if os.path.exists(path):
|
|
447
|
+
f = _load_truetype(path, 100, idx)
|
|
448
|
+
if f is not None:
|
|
449
|
+
break
|
|
450
|
+
_font_cache[key] = f
|
|
451
|
+
return f
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def cjk_font_family_css() -> str:
|
|
455
|
+
"""CSS font-family stack that includes a CJK face when available on the box."""
|
|
456
|
+
if _cjk_font(False) is not None:
|
|
457
|
+
return (
|
|
458
|
+
"'Noto Sans CJK SC', 'Noto Sans CJK JP', 'Source Han Sans SC', "
|
|
459
|
+
"DejaVu Sans, Helvetica, Arial, sans-serif"
|
|
460
|
+
)
|
|
461
|
+
return "DejaVu Sans, Helvetica, Arial, sans-serif"
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def sym_font_family_css() -> str:
|
|
465
|
+
return "'DejaVu Sans', sans-serif"
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _sym_font(bold: bool):
|
|
469
|
+
with use_typeface("sans"):
|
|
470
|
+
return _latin_font(False, bold)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _font_for_script(script: str, mono: bool, bold: bool, italic: bool = False):
|
|
474
|
+
if script == "sym":
|
|
475
|
+
return _sym_font(bold)
|
|
476
|
+
if script == "cjk":
|
|
477
|
+
f = _cjk_font(bold)
|
|
478
|
+
if f is not None:
|
|
479
|
+
return f
|
|
480
|
+
return _latin_font(mono, bold, italic)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _run_width(run: str, script: str, font_size: float, mono: bool, bold: bool,
|
|
484
|
+
italic: bool = False) -> float:
|
|
485
|
+
f = _font_for_script(script, mono, bold, italic)
|
|
486
|
+
if f is not None:
|
|
487
|
+
return f.getlength(run) * font_size / 100.0
|
|
488
|
+
ratio = _MONO_RATIO if mono else _AVG_RATIO
|
|
489
|
+
if script == "cjk":
|
|
490
|
+
return len(run) * font_size * _CJK_RATIO
|
|
491
|
+
return len(run) * font_size * ratio * (1.08 if bold else 1.0)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _plain_line_width(line: str, font_size: float, mono: bool, bold: bool,
|
|
495
|
+
italic: bool = False) -> float:
|
|
496
|
+
"""Sum per-script run widths (Latin face for Latin, Noto CJK for CJK)."""
|
|
497
|
+
if not line:
|
|
498
|
+
return 0.0
|
|
499
|
+
if not needs_script_split(line):
|
|
500
|
+
return _run_width(line, "latin", font_size, mono, bold, italic)
|
|
501
|
+
return sum(
|
|
502
|
+
_run_width(run, script, font_size, mono, bold, italic)
|
|
503
|
+
for script, run in script_runs(line)
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def measure_text(
|
|
508
|
+
text: str,
|
|
509
|
+
font_size: float,
|
|
510
|
+
*,
|
|
511
|
+
mono: bool = False,
|
|
512
|
+
weight: str = "normal",
|
|
513
|
+
line_height: float = 1.25,
|
|
514
|
+
italic: bool = False,
|
|
515
|
+
) -> Tuple[float, float]:
|
|
516
|
+
"""
|
|
517
|
+
Text block size in px.
|
|
518
|
+
|
|
519
|
+
Rich-text markup (``**bold**``, ``*italic*``, ```code```, ``[color=#c00]…[/]``,
|
|
520
|
+
``[bg=#ffe08a]…[/]``; see :func:`parse_rich`) is measured run by run with the
|
|
521
|
+
matching bold / italic / mono faces.
|
|
522
|
+
|
|
523
|
+
Uses Pillow with per-run Latin (DejaVu) ↔ CJK (Noto Sans CJK SC) switching for
|
|
524
|
+
mixed strings; falls back to a per-script glyph-ratio heuristic. Math markup
|
|
525
|
+
(`q_{i1}`, `x^{2}`) is measured with sub/super at 0.75× size.
|
|
526
|
+
"""
|
|
527
|
+
from .styles import current_theme
|
|
528
|
+
|
|
529
|
+
font_size = current_theme().snap(font_size)
|
|
530
|
+
if not text:
|
|
531
|
+
return 0.0, font_size * line_height
|
|
532
|
+
bold = str(weight) in ("bold", "600", "700", "800", "900")
|
|
533
|
+
if has_rich_markup(text):
|
|
534
|
+
lines = parse_rich(text)
|
|
535
|
+
w = max((rich_line_width(ln, font_size, mono=mono, bold=bold, italic=italic)
|
|
536
|
+
for ln in lines), default=0.0)
|
|
537
|
+
return w, max(1, len(lines)) * font_size * line_height
|
|
538
|
+
# Measure line by line with markup awareness
|
|
539
|
+
lines_segs: List[List[MathSeg]] = [[]]
|
|
540
|
+
for kind, body in parse_math_markup(text):
|
|
541
|
+
if kind == "nl":
|
|
542
|
+
lines_segs.append([])
|
|
543
|
+
else:
|
|
544
|
+
lines_segs[-1].append((kind, body))
|
|
545
|
+
|
|
546
|
+
max_w = 0.0
|
|
547
|
+
for segs in lines_segs:
|
|
548
|
+
if not segs:
|
|
549
|
+
max_w = max(max_w, 0.0)
|
|
550
|
+
continue
|
|
551
|
+
# If no sub/super, measure whole plain line (better kerning)
|
|
552
|
+
if all(k == "text" for k, _ in segs):
|
|
553
|
+
plain = "".join(b for _, b in segs)
|
|
554
|
+
max_w = max(max_w, _plain_line_width(plain, font_size, mono, bold, italic))
|
|
555
|
+
continue
|
|
556
|
+
w = _segs_width(segs, font_size, mono, bold, italic)
|
|
557
|
+
max_w = max(max_w, w)
|
|
558
|
+
|
|
559
|
+
height = max(1, len(lines_segs)) * font_size * line_height
|
|
560
|
+
return max_w, height
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _segs_width(segs, font_size: float, mono: bool, bold: bool, italic: bool = False) -> float:
|
|
564
|
+
"""Width of one line of math segments (text / sub / super)."""
|
|
565
|
+
from .styles import current_theme
|
|
566
|
+
|
|
567
|
+
th = current_theme()
|
|
568
|
+
sfs = th.script_size(font_size)
|
|
569
|
+
if th.font_sizes: # paper-style themes: measure exactly what draw_text renders
|
|
570
|
+
ws = [_plain_line_width(body, sfs if kind in ("sub", "super") else font_size,
|
|
571
|
+
mono, bold, italic if kind == "text" else False)
|
|
572
|
+
for kind, body in segs]
|
|
573
|
+
return sum(stack_script_advances([k for k, _ in segs], ws))
|
|
574
|
+
if True:
|
|
575
|
+
w = 0.0
|
|
576
|
+
for kind, body in segs:
|
|
577
|
+
fs = sfs if kind in ("sub", "super") else font_size
|
|
578
|
+
# Prefer unicode-collapsed form for font getlength on sub/super
|
|
579
|
+
if kind == "sub":
|
|
580
|
+
body_m = body.translate(_SUB_MAP)
|
|
581
|
+
# If translation left ASCII (unknown glyphs), measure at 0.75 with original
|
|
582
|
+
if body_m == body:
|
|
583
|
+
w += _plain_line_width(body, fs, mono, bold)
|
|
584
|
+
else:
|
|
585
|
+
w += _plain_line_width(body_m, font_size, mono, bold) * max(0.85, sfs / font_size)
|
|
586
|
+
elif kind == "super":
|
|
587
|
+
body_m = body.translate(_SUPER_MAP)
|
|
588
|
+
if body_m == body:
|
|
589
|
+
w += _plain_line_width(body, fs, mono, bold)
|
|
590
|
+
else:
|
|
591
|
+
w += _plain_line_width(body_m, font_size, mono, bold) * max(0.85, sfs / font_size)
|
|
592
|
+
else:
|
|
593
|
+
w += _plain_line_width(body, fs, mono, bold, italic)
|
|
594
|
+
return w
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def xml_escape(s: str) -> str:
|
|
598
|
+
return (
|
|
599
|
+
s.replace("&", "&")
|
|
600
|
+
.replace("<", "<")
|
|
601
|
+
.replace(">", ">")
|
|
602
|
+
.replace('"', """)
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def split_lines(text: str) -> List[str]:
|
|
607
|
+
"""Plain (unicode-expanded) lines — for simple Text layout loops."""
|
|
608
|
+
return expand_subscripts(text).split("\n")
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def math_lines(text: str) -> List[List[MathSeg]]:
|
|
612
|
+
"""Segment lists per visual line (for SVG tspan rendering)."""
|
|
613
|
+
lines: List[List[MathSeg]] = [[]]
|
|
614
|
+
for kind, body in parse_math_markup(text):
|
|
615
|
+
if kind == "nl":
|
|
616
|
+
lines.append([])
|
|
617
|
+
else:
|
|
618
|
+
lines[-1].append((kind, body))
|
|
619
|
+
return lines
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
# ---- rich text (inline spans) ----------------------------------------------
|
|
623
|
+
#
|
|
624
|
+
# A deliberately tiny markup usable in *every* label (Text, Box text / subtitle,
|
|
625
|
+
# Group titles, edge labels, Matrix cells, Badges, Legend keys):
|
|
626
|
+
#
|
|
627
|
+
# **bold** *italic* `mono` [color=#c00]red[/] [bg=#ffe08a]highlight[/]
|
|
628
|
+
# [b]…[/] [i]…[/] [u]underline[/] [mono]…[/] [#c00]…[/] (colour shorthand)
|
|
629
|
+
# tags combine: [color=#c00 bg=#fee b]…[/] escapes: \* \` \[ \\
|
|
630
|
+
#
|
|
631
|
+
# Existing math markup (_{…} / ^{…}) keeps working inside any span. Unknown
|
|
632
|
+
# bracket text such as "[...]" is literal.
|
|
633
|
+
|
|
634
|
+
from dataclasses import dataclass as _dataclass, replace as _dc_replace
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
@_dataclass(frozen=True)
|
|
638
|
+
class Run:
|
|
639
|
+
"""One styled run of text (no newlines)."""
|
|
640
|
+
|
|
641
|
+
text: str
|
|
642
|
+
bold: bool = False
|
|
643
|
+
italic: bool = False
|
|
644
|
+
mono: bool = False
|
|
645
|
+
color: Optional[str] = None
|
|
646
|
+
bg: Optional[str] = None
|
|
647
|
+
underline: bool = False
|
|
648
|
+
kind: str = "text" # text | sub | super
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
_TAG_RE = re.compile(r"\[(/|[#a-zA-Z][^\[\]\n]{0,60})\]")
|
|
652
|
+
_COLOR_RE = re.compile(r"^(#[0-9a-fA-F]{3,8}|[a-zA-Z]+|rgb\([0-9., %]+\))$")
|
|
653
|
+
_RICH_HINT_RE = re.compile(r"\*\*|(?<![\\\w*])\*(?=[^\s*])|`|\\[*`\[\\]|\[(?:/|#|b\]|i\]|u\]|mono\]|color=|bg=)")
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _parse_tag(body: str) -> Optional[dict]:
|
|
657
|
+
"""``color=#c00 bg=#fee b`` → style dict, or None when not a valid tag."""
|
|
658
|
+
out: dict = {}
|
|
659
|
+
for tok in body.replace(",", " ").split():
|
|
660
|
+
if "=" in tok:
|
|
661
|
+
k, v = tok.split("=", 1)
|
|
662
|
+
k = k.lower()
|
|
663
|
+
if k in ("color", "c", "fg") and _COLOR_RE.match(v):
|
|
664
|
+
out["color"] = v
|
|
665
|
+
elif k in ("bg", "highlight", "hl") and _COLOR_RE.match(v):
|
|
666
|
+
out["bg"] = v
|
|
667
|
+
else:
|
|
668
|
+
return None
|
|
669
|
+
elif tok.startswith("#") and _COLOR_RE.match(tok):
|
|
670
|
+
out["color"] = tok
|
|
671
|
+
elif tok.lower() in ("b", "bold"):
|
|
672
|
+
out["bold"] = True
|
|
673
|
+
elif tok.lower() in ("i", "italic", "em"):
|
|
674
|
+
out["italic"] = True
|
|
675
|
+
elif tok.lower() in ("u", "underline"):
|
|
676
|
+
out["underline"] = True
|
|
677
|
+
elif tok.lower() in ("mono", "code", "tt"):
|
|
678
|
+
out["mono"] = True
|
|
679
|
+
else:
|
|
680
|
+
return None
|
|
681
|
+
return out or None
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def has_rich_markup(text: str) -> bool:
|
|
685
|
+
"""Cheap test: does ``text`` contain inline span markup?"""
|
|
686
|
+
if not text or not _RICH_HINT_RE.search(text):
|
|
687
|
+
return False
|
|
688
|
+
lines = parse_rich(text)
|
|
689
|
+
flat = [r for ln in lines for r in ln]
|
|
690
|
+
return any(r.bold or r.italic or r.mono or r.color or r.bg or r.underline for r in flat) or \
|
|
691
|
+
"".join(r.text for r in flat if r.kind == "text") != _strip_math_free(text)
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _strip_math_free(text: str) -> str:
|
|
695
|
+
return "".join(b for k, b in parse_math_markup(text) if k == "text")
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def parse_rich(text: str) -> List[List[Run]]:
|
|
699
|
+
"""Parse inline markup into lines of :class:`Run` (math sub/super split out)."""
|
|
700
|
+
lines: List[List[Run]] = [[]]
|
|
701
|
+
stack: List[Run] = [Run("")] # style stack (text unused)
|
|
702
|
+
bold_on = italic_on = mono_on = False
|
|
703
|
+
buf: List[str] = []
|
|
704
|
+
|
|
705
|
+
def style() -> Run:
|
|
706
|
+
st = stack[-1]
|
|
707
|
+
return _dc_replace(st, bold=st.bold or bold_on, italic=st.italic or italic_on,
|
|
708
|
+
mono=st.mono or mono_on)
|
|
709
|
+
|
|
710
|
+
def flush() -> None:
|
|
711
|
+
if not buf:
|
|
712
|
+
return
|
|
713
|
+
chunk = "".join(buf)
|
|
714
|
+
buf.clear()
|
|
715
|
+
st = style()
|
|
716
|
+
for kind, body in parse_math_markup(chunk) if not st.mono else [("text", chunk)]:
|
|
717
|
+
if kind == "nl":
|
|
718
|
+
lines.append([])
|
|
719
|
+
elif body:
|
|
720
|
+
lines[-1].append(_dc_replace(st, text=body, kind=kind))
|
|
721
|
+
|
|
722
|
+
i, n = 0, len(text)
|
|
723
|
+
while i < n:
|
|
724
|
+
ch = text[i]
|
|
725
|
+
if ch == "\\" and i + 1 < n and text[i + 1] in "*`[\\_^":
|
|
726
|
+
buf.append(text[i + 1] if text[i + 1] not in "_^" else text[i + 1])
|
|
727
|
+
i += 2
|
|
728
|
+
continue
|
|
729
|
+
if mono_on and ch != "`":
|
|
730
|
+
if ch == "\n":
|
|
731
|
+
flush()
|
|
732
|
+
lines.append([])
|
|
733
|
+
else:
|
|
734
|
+
buf.append(ch)
|
|
735
|
+
i += 1
|
|
736
|
+
continue
|
|
737
|
+
if ch == "`":
|
|
738
|
+
flush()
|
|
739
|
+
mono_on = not mono_on
|
|
740
|
+
i += 1
|
|
741
|
+
continue
|
|
742
|
+
if text.startswith("**", i) and (bold_on or (i + 2 < n and not text[i + 2].isspace())):
|
|
743
|
+
flush()
|
|
744
|
+
bold_on = not bold_on
|
|
745
|
+
i += 2
|
|
746
|
+
continue
|
|
747
|
+
if ch == "*":
|
|
748
|
+
prev = text[i - 1] if i > 0 else " "
|
|
749
|
+
nxt = text[i + 1] if i + 1 < n else " "
|
|
750
|
+
opening = (not italic_on) and not nxt.isspace() and not (prev.isalnum())
|
|
751
|
+
closing = italic_on and not prev.isspace()
|
|
752
|
+
if opening or closing:
|
|
753
|
+
flush()
|
|
754
|
+
italic_on = not italic_on
|
|
755
|
+
i += 1
|
|
756
|
+
continue
|
|
757
|
+
if ch == "[":
|
|
758
|
+
m = _TAG_RE.match(text, i)
|
|
759
|
+
if m:
|
|
760
|
+
body = m.group(1)
|
|
761
|
+
if body == "/":
|
|
762
|
+
if len(stack) > 1:
|
|
763
|
+
flush()
|
|
764
|
+
stack.pop()
|
|
765
|
+
i = m.end()
|
|
766
|
+
continue
|
|
767
|
+
else:
|
|
768
|
+
spec = _parse_tag(body)
|
|
769
|
+
if spec is not None:
|
|
770
|
+
flush()
|
|
771
|
+
cur = stack[-1]
|
|
772
|
+
stack.append(_dc_replace(
|
|
773
|
+
cur,
|
|
774
|
+
bold=cur.bold or spec.get("bold", False),
|
|
775
|
+
italic=cur.italic or spec.get("italic", False),
|
|
776
|
+
mono=cur.mono or spec.get("mono", False),
|
|
777
|
+
underline=cur.underline or spec.get("underline", False),
|
|
778
|
+
color=spec.get("color", cur.color),
|
|
779
|
+
bg=spec.get("bg", cur.bg),
|
|
780
|
+
))
|
|
781
|
+
i = m.end()
|
|
782
|
+
continue
|
|
783
|
+
if ch == "\n":
|
|
784
|
+
flush()
|
|
785
|
+
lines.append([])
|
|
786
|
+
i += 1
|
|
787
|
+
continue
|
|
788
|
+
buf.append(ch)
|
|
789
|
+
i += 1
|
|
790
|
+
flush()
|
|
791
|
+
return lines
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
def strip_markup(text: str) -> str:
|
|
795
|
+
"""Plain text of a (possibly marked-up) label (math markup kept)."""
|
|
796
|
+
if not has_rich_markup(text):
|
|
797
|
+
return text
|
|
798
|
+
out = []
|
|
799
|
+
for ln in parse_rich(text):
|
|
800
|
+
parts = []
|
|
801
|
+
for r in ln:
|
|
802
|
+
parts.append(r.text if r.kind == "text" else ("_{" if r.kind == "sub" else "^{") + r.text + "}")
|
|
803
|
+
out.append("".join(parts))
|
|
804
|
+
return "\n".join(out)
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def run_width(r: Run, font_size: float, *, mono: bool = False, bold: bool = False,
|
|
808
|
+
italic: bool = False) -> float:
|
|
809
|
+
b, it, mo = (bold or r.bold), (italic or r.italic), (mono or r.mono)
|
|
810
|
+
if r.kind in ("sub", "super"):
|
|
811
|
+
return _segs_width([(r.kind, r.text)], font_size, mo, b, it)
|
|
812
|
+
return _plain_line_width(r.text, font_size, mo, b, it)
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def stack_script_advances(kinds: Sequence[str], ws: Sequence[float]) -> List[float]:
|
|
816
|
+
"""v0.13.0: a subscript directly followed by a superscript (or the reverse) is
|
|
817
|
+
stacked — ``H_1^+`` draws ⁺ above ₁ — so the first script advances 0 and the
|
|
818
|
+
second advances the wider of the two."""
|
|
819
|
+
out = list(ws)
|
|
820
|
+
i = 0
|
|
821
|
+
while i < len(kinds) - 1:
|
|
822
|
+
if {kinds[i], kinds[i + 1]} == {"sub", "super"}:
|
|
823
|
+
out[i + 1] = max(out[i], out[i + 1])
|
|
824
|
+
out[i] = 0.0
|
|
825
|
+
i += 2
|
|
826
|
+
else:
|
|
827
|
+
i += 1
|
|
828
|
+
return out
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def run_widths(runs: Sequence[Run], font_size: float, *, mono: bool = False,
|
|
832
|
+
bold: bool = False, italic: bool = False) -> List[float]:
|
|
833
|
+
"""Advance of each run, with a v0.12.0 italic correction: an upright run that
|
|
834
|
+
ends in a space before an italic run (and an italic run before an upright one)
|
|
835
|
+
gets ~0.12 em extra, so ``"gate *G*"`` keeps a visible space (slanted glyphs
|
|
836
|
+
otherwise eat it)."""
|
|
837
|
+
ws = stack_script_advances([r.kind for r in runs],
|
|
838
|
+
[run_width(r, font_size, mono=mono, bold=bold, italic=italic)
|
|
839
|
+
for r in runs])
|
|
840
|
+
if italic:
|
|
841
|
+
return ws
|
|
842
|
+
for i in range(len(runs) - 1):
|
|
843
|
+
a, b = runs[i], runs[i + 1]
|
|
844
|
+
if b.italic and not a.italic and a.text.endswith(" "):
|
|
845
|
+
ws[i] += 0.12 * font_size
|
|
846
|
+
elif a.italic and not b.italic and b.text.startswith(" "):
|
|
847
|
+
ws[i] += 0.08 * font_size
|
|
848
|
+
return ws
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def rich_line_width(runs: Sequence[Run], font_size: float, *, mono: bool = False,
|
|
852
|
+
bold: bool = False, italic: bool = False) -> float:
|
|
853
|
+
return sum(run_widths(runs, font_size, mono=mono, bold=bold, italic=italic))
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
def _escape_markup(s: str) -> str:
|
|
857
|
+
return s.replace("\\", "\\\\").replace("*", "\\*").replace("`", "\\`").replace("[", "\\[")
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def markup_lines(text: str) -> List[str]:
|
|
861
|
+
"""Split ``text`` into visual lines, each a self-contained markup string
|
|
862
|
+
(spans that cross a newline are re-opened on the next line)."""
|
|
863
|
+
if not has_rich_markup(text):
|
|
864
|
+
return text.split("\n")
|
|
865
|
+
out = []
|
|
866
|
+
for ln in parse_rich(text):
|
|
867
|
+
parts = []
|
|
868
|
+
for r in ln:
|
|
869
|
+
body = _escape_markup(r.text)
|
|
870
|
+
if r.kind == "sub":
|
|
871
|
+
body = "_{" + body + "}"
|
|
872
|
+
elif r.kind == "super":
|
|
873
|
+
body = "^{" + body + "}"
|
|
874
|
+
tags = []
|
|
875
|
+
if r.bold:
|
|
876
|
+
tags.append("b")
|
|
877
|
+
if r.italic:
|
|
878
|
+
tags.append("i")
|
|
879
|
+
if r.mono:
|
|
880
|
+
tags.append("mono")
|
|
881
|
+
if r.underline:
|
|
882
|
+
tags.append("u")
|
|
883
|
+
if r.color:
|
|
884
|
+
tags.append(f"color={r.color}")
|
|
885
|
+
if r.bg:
|
|
886
|
+
tags.append(f"bg={r.bg}")
|
|
887
|
+
parts.append(f"[{' '.join(tags)}]{body}[/]" if tags else body)
|
|
888
|
+
out.append("".join(parts))
|
|
889
|
+
return out
|