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/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("&", "&amp;")
600
+ .replace("<", "&lt;")
601
+ .replace(">", "&gt;")
602
+ .replace('"', "&quot;")
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