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/code.py ADDED
@@ -0,0 +1,602 @@
1
+ """CodeBlock: monospace source listing with light syntax colouring and line ports.
2
+
3
+ A built-in regex tokenizer (no Pygments) colours keywords, strings, comments,
4
+ numbers, function / method names, decorators and builtins for ``python`` and
5
+ ``sql``; ``text`` is shown verbatim (useful for traces, prompts, logs).
6
+
7
+ The source is rendered literally — ``*``, ``[`` and backticks in code are never
8
+ treated as codefig rich-text markup. Per-line *annotations* (shown to the right of
9
+ a line) do use rich text (``**bold**``, ``[color=#c00]…[/]``).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import keyword
15
+ import re
16
+ import textwrap
17
+ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple, Union
18
+
19
+ from .node import Node
20
+ from .styles import Theme, themed
21
+
22
+ CODE_LANGS = ("python", "sql", "text")
23
+ TOKEN_TYPES = ("plain", "keyword", "builtin", "string", "comment", "number", "function",
24
+ "decorator", "operator")
25
+
26
+ # ---------------------------------------------------------------------------- palettes
27
+
28
+ #: Built-in syntax palettes for ``CodeBlock(theme=...)``. Keys: token types plus
29
+ #: ``bg`` (background), ``border``, ``gutter`` (line-number colour), ``highlight``
30
+ #: (highlighted-line band), ``title_bg`` / ``title`` (optional header bar).
31
+ CODE_THEMES: Dict[str, Dict[str, str]] = {
32
+ "light": {
33
+ "bg": "#f6f8fa", "border": "#c9d1d9", "plain": "#24292f", "keyword": "#cf222e",
34
+ "builtin": "#953800", "string": "#0a3069", "comment": "#6e7781", "number": "#0550ae",
35
+ "function": "#8250df", "decorator": "#8250df", "operator": "#24292f",
36
+ "gutter": "#8c959f", "highlight": "#fff3b0", "title_bg": "#eaeef2", "title": "#24292f",
37
+ },
38
+ "dark": {
39
+ "bg": "#282c34", "border": "#3b4048", "plain": "#abb2bf", "keyword": "#c678dd",
40
+ "builtin": "#e5c07b", "string": "#98c379", "comment": "#7f848e", "number": "#d19a66",
41
+ "function": "#61afef", "decorator": "#e5c07b", "operator": "#abb2bf",
42
+ "gutter": "#636d83", "highlight": "#3e4451", "title_bg": "#21252b", "title": "#d7dae0",
43
+ },
44
+ "paper": { # print-friendly, few colours (keywords bold blue, strings green)
45
+ "bg": "#ffffff", "border": "#888888", "plain": "#111111", "keyword": "#1f4e9c",
46
+ "builtin": "#1f4e9c", "string": "#2e7d32", "comment": "#777777", "number": "#b35900",
47
+ "function": "#111111", "decorator": "#7a3e9d", "operator": "#111111",
48
+ "gutter": "#999999", "highlight": "#fff2a8", "title_bg": "#eeeeee", "title": "#111111",
49
+ },
50
+ "mono": { # black & white: structure via weight / italics only
51
+ "bg": "#ffffff", "border": "#555555", "plain": "#111111", "keyword": "#111111",
52
+ "builtin": "#111111", "string": "#444444", "comment": "#777777", "number": "#111111",
53
+ "function": "#111111", "decorator": "#111111", "operator": "#111111",
54
+ "gutter": "#999999", "highlight": "#e6e6e6", "title_bg": "#eeeeee", "title": "#111111",
55
+ },
56
+ }
57
+ _BOLD = {"light": {"keyword"}, "dark": set(), "paper": {"keyword"}, "mono": {"keyword"}}
58
+ _ITALIC = {"comment"}
59
+
60
+ # ---------------------------------------------------------------------------- tokenizer
61
+
62
+ _PY_KW = set(keyword.kwlist) | {"match", "case", "self"}
63
+ _PY_BUILTINS = {
64
+ "print", "len", "range", "enumerate", "zip", "map", "filter", "sorted", "sum", "min",
65
+ "max", "abs", "int", "float", "str", "list", "dict", "set", "tuple", "bool", "open",
66
+ "isinstance", "super", "type", "any", "all", "iter", "next", "round", "object",
67
+ }
68
+ _SQL_KW = {
69
+ "select", "from", "where", "join", "inner", "left", "right", "full", "outer", "cross",
70
+ "on", "group", "by", "order", "limit", "offset", "as", "and", "or", "not", "in", "is",
71
+ "null", "insert", "into", "values", "update", "set", "delete", "create", "table", "view",
72
+ "index", "drop", "alter", "with", "case", "when", "then", "else", "end", "distinct",
73
+ "having", "union", "all", "asc", "desc", "like", "between", "exists", "cast", "over",
74
+ "partition", "primary", "key", "foreign", "references", "using", "natural", "true",
75
+ "false", "if", "returns", "return", "begin", "commit", "intersect", "except", "top",
76
+ }
77
+ _SQL_FUNCS = {"count", "sum", "avg", "min", "max", "coalesce", "lower", "upper", "substr",
78
+ "round", "abs", "length", "date", "now", "row_number", "rank"}
79
+
80
+ _PY_RE = re.compile(
81
+ r"""
82
+ (?P<comment>\#[^\n]*)
83
+ | (?P<string>(?:[rRbBuUfF]{1,2})?(?:\"\"\"[\s\S]*?(?:\"\"\"|\Z)|'''[\s\S]*?(?:'''|\Z)
84
+ |"(?:\\.|[^"\\\n])*"?|'(?:\\.|[^'\\\n])*'?))
85
+ | (?P<decorator>@[A-Za-z_][\w.]*)
86
+ | (?P<number>\b(?:0[xX][0-9a-fA-F_]+|\d[\d_]*\.?\d*(?:[eE][+-]?\d+)?j?|\.\d+)\b)
87
+ | (?P<name>[A-Za-z_]\w*)
88
+ | (?P<space>[ \t]+)
89
+ | (?P<newline>\n)
90
+ | (?P<operator>[^\w\s])
91
+ """,
92
+ re.VERBOSE,
93
+ )
94
+ _SQL_RE = re.compile(
95
+ r"""
96
+ (?P<comment>--[^\n]*|/\*[\s\S]*?(?:\*/|\Z))
97
+ | (?P<string>'(?:''|[^'])*'?|"(?:[^"])*"?|`[^`]*`?)
98
+ | (?P<number>\b\d+(?:\.\d+)?\b)
99
+ | (?P<name>[A-Za-z_][\w$]*)
100
+ | (?P<space>[ \t]+)
101
+ | (?P<newline>\n)
102
+ | (?P<operator>[^\w\s])
103
+ """,
104
+ re.VERBOSE,
105
+ )
106
+
107
+ Token = Tuple[str, str] # (type, text)
108
+
109
+
110
+ def tokenize(source: str, lang: str = "python") -> List[List[Token]]:
111
+ """Split ``source`` into lines of ``(token_type, text)`` pairs.
112
+
113
+ ``token_type`` is one of :data:`TOKEN_TYPES`. Multi-line strings / comments are
114
+ split at newlines (each piece keeps its type). ``lang`` ∈ :data:`CODE_LANGS`.
115
+
116
+ Example:
117
+ >>> tokenize("x = len(s) # n", "python")[0][-1]
118
+ ('comment', '# n')
119
+ """
120
+ if lang not in CODE_LANGS:
121
+ raise ValueError(f"lang must be one of {CODE_LANGS}, got {lang!r}")
122
+ lines: List[List[Token]] = [[]]
123
+
124
+ def emit(kind: str, text: str) -> None:
125
+ parts = text.split("\n")
126
+ for i, part in enumerate(parts):
127
+ if i:
128
+ lines.append([])
129
+ if part:
130
+ lines[-1].append((kind, part))
131
+
132
+ if lang == "text":
133
+ emit("plain", source)
134
+ return lines
135
+ rx = _PY_RE if lang == "python" else _SQL_RE
136
+ toks = [(m.lastgroup or "plain", m.group()) for m in rx.finditer(source)]
137
+ prev_sig = "" # previous significant token text (for def / class / .attr)
138
+ for i, (kind, text) in enumerate(toks):
139
+ if kind == "newline":
140
+ lines.append([])
141
+ continue
142
+ if kind == "space":
143
+ emit("plain", text)
144
+ continue
145
+ if kind == "name":
146
+ nxt = ""
147
+ for k2, t2 in toks[i + 1:]:
148
+ if k2 != "space":
149
+ nxt = t2
150
+ break
151
+ if lang == "python":
152
+ if text in _PY_KW:
153
+ kind = "keyword"
154
+ elif prev_sig in ("def", "class"):
155
+ kind = "function"
156
+ elif nxt == "(":
157
+ kind = "builtin" if (text in _PY_BUILTINS and prev_sig != ".") else "function"
158
+ else:
159
+ kind = "plain"
160
+ else:
161
+ low = text.lower()
162
+ if nxt == "(" and (low in _SQL_FUNCS or low not in _SQL_KW):
163
+ kind = "function"
164
+ elif low in _SQL_KW:
165
+ kind = "keyword"
166
+ else:
167
+ kind = "plain"
168
+ emit(kind, text)
169
+ prev_sig = text
170
+ return lines
171
+
172
+
173
+ # ---------------------------------------------------------------------------- node
174
+
175
+ class CodeLineRef:
176
+ """Anchors of one CodeBlock line (``top`` / ``bottom`` / ``cy`` of the line band,
177
+ ``left`` / ``right`` / ``cx`` of the block) — use with constraints / ``align=``."""
178
+
179
+ def __init__(self, block: "CodeBlock", line: int) -> None:
180
+ self.node, self.line = block, int(line)
181
+
182
+ def _anchor(self, axis: str, which: str):
183
+ from .constraints import Anchor, _WHICH_FRAC
184
+
185
+ b, ln, f = self.node, self.line, _WHICH_FRAC[which]
186
+ if axis == "y":
187
+ def get() -> float:
188
+ y0, h = b._line_band(ln)
189
+ return y0 + h * f
190
+ else:
191
+ def get() -> float:
192
+ g = b.geom
193
+ return g.x + g.w * f
194
+ name = {"start": "top" if axis == "y" else "left", "end": "bottom" if axis == "y" else "right",
195
+ "center": "cy" if axis == "y" else "cx"}[which]
196
+ return Anchor(b, axis, getter=get, label=f"line[{ln}].{name}")
197
+
198
+ left = property(lambda self: self._anchor("x", "start"))
199
+ right = property(lambda self: self._anchor("x", "end"))
200
+ top = property(lambda self: self._anchor("y", "start"))
201
+ bottom = property(lambda self: self._anchor("y", "end"))
202
+ cx = property(lambda self: self._anchor("x", "center"))
203
+ cy = property(lambda self: self._anchor("y", "center"))
204
+
205
+
206
+ class CodeBlock(Node):
207
+ """Source-code / SQL / trace listing in a monospace font with syntax colours.
208
+
209
+ Layering role: **hard** (edges route around it and may end on its border; use
210
+ :meth:`line_port` to point an arrow at one line).
211
+
212
+ Args:
213
+ source: The code. Common leading indentation and leading / trailing blank
214
+ lines are removed (``dedent=True``); tabs expand to ``tab_size`` spaces.
215
+ lang: ``"python"`` (default), ``"sql"`` or ``"text"`` (no colouring).
216
+ token_colors: Mapping of literal keys or ``re:`` regex keys to token types,
217
+ CSS colours, or dicts with color/bold/italic. Literals require Unicode
218
+ word boundaries (Model excludes Models and _Model). Longest overlapping
219
+ match wins; insertion order breaks ties. Matches may span lines.
220
+ line_numbers: Show a line-number gutter (default ``False``).
221
+ start_line: Number of the first line (default 1). Line numbers used by
222
+ ``highlight_lines``, ``annotations``, :meth:`line_port` are these numbers.
223
+ highlight_lines: Lines to mark with a background band — an iterable of line
224
+ numbers, or a ``{line: colour}`` mapping for per-line colours.
225
+ font_size: Code size in pt (default ``0.85 × theme.font_size`` ≈ 6 pt on the paper theme; a number is pt at final size, like every other font_size).
226
+ theme: Syntax palette — ``"light"`` (default), ``"dark"``, ``"paper"``,
227
+ ``"mono"``, or a dict overriding palette keys (see :data:`CODE_THEMES`;
228
+ ``"base"`` picks the palette it starts from; unknown keys are rejected). A :class:`~codefig.styles.Theme` instance is
229
+ accepted too and sets the node's figure theme instead.
230
+ title: Optional header bar text (e.g. a file name or ``"SQL"``), drawn
231
+ literally (no rich-text / subscript markup).
232
+ annotations: ``{line: rich_text}`` notes drawn right of those lines in the
233
+ sans font (rich markup allowed: ``"← **join** key"``).
234
+ annotation_color: Colour of annotations (default ``theme.highlight``).
235
+ fill: Background override (default: palette ``bg``).
236
+ stroke: Border colour override (default: palette ``border``); ``border=``
237
+ (``"dashed"``, ``"none"`` …) styles the outline as for any node.
238
+ stroke_width: Border width (default: the theme's thin stroke; 1 px classic).
239
+ rounded: Rounded corners (default ``True``, radius 4 pt).
240
+ pad: Inner padding in pt around the code (default 8).
241
+ line_height: Line pitch as a multiple of ``font_size`` (default 1.4).
242
+ tab_size: Spaces per tab (default 4).
243
+ dedent: Strip common indentation / blank edge lines (default ``True``).
244
+ **kwargs: Common :class:`~codefig.node.Node` options (``id``, ``w``, ``min_w``,
245
+ ``obstacle``, ``legend`` …).
246
+
247
+ Example:
248
+ >>> from codefig import Figure, CodeBlock, Box, Row, Diagram, Edge
249
+ >>> code = CodeBlock('''
250
+ ... def digest(df):
251
+ ... out = df.sem_map("summarise {abstract}")
252
+ ... return out.sem_agg("merge")
253
+ ... ''', line_numbers=True, highlight_lines=[2])
254
+ >>> custom = CodeBlock("Model", lang="text", token_colors={"Model": "function"})
255
+ >>> llm = Box("LLM")
256
+ >>> d = Diagram(Row([code, llm], gap=40), [Edge(code.line_port(2, "right"), llm)])
257
+ >>> fig = Figure(size="auto", cols=1)
258
+ >>> fig.panel("a").set(d) # doctest: +ELLIPSIS
259
+ <codefig.figure.Panel object at ...>
260
+ >>> svg = fig.to_svg()
261
+ """
262
+
263
+ stroke_width = themed(1.0, "SW")
264
+ pad = themed(8.0)
265
+
266
+ def __init__(
267
+ self,
268
+ source: str,
269
+ *,
270
+ lang: str = "python",
271
+ token_colors: Optional[Mapping[str, Any]] = None,
272
+ line_numbers: bool = False,
273
+ start_line: int = 1,
274
+ highlight_lines: Optional[Union[Iterable[int], Mapping[int, str]]] = None,
275
+ font_size: Optional[float] = None,
276
+ theme: Optional[Union[str, Mapping[str, str], Theme]] = None,
277
+ title: Optional[str] = None,
278
+ annotations: Optional[Mapping[int, str]] = None,
279
+ annotation_color: Optional[str] = None,
280
+ fill: Optional[str] = None,
281
+ stroke: Optional[str] = None,
282
+ stroke_width: Optional[float] = None,
283
+ rounded: bool = True,
284
+ pad: Optional[float] = None,
285
+ line_height: float = 1.4,
286
+ tab_size: int = 4,
287
+ dedent: bool = True,
288
+ **kwargs: Any,
289
+ ) -> None:
290
+ palette_spec: Union[str, Mapping[str, str]] = "light"
291
+ if isinstance(theme, Theme):
292
+ kwargs["theme"] = theme
293
+ elif theme is not None:
294
+ palette_spec = theme
295
+ super().__init__(**kwargs)
296
+ if lang not in CODE_LANGS:
297
+ raise ValueError(f"lang must be one of {CODE_LANGS}, got {lang!r}")
298
+ self.lang = lang
299
+ if isinstance(palette_spec, str):
300
+ if palette_spec not in CODE_THEMES:
301
+ raise ValueError(f"CodeBlock theme must be one of {tuple(CODE_THEMES)} or a dict")
302
+ self.palette_name = palette_spec
303
+ self.palette = dict(CODE_THEMES[palette_spec])
304
+ else:
305
+ bad = set(palette_spec) - set(CODE_THEMES["light"]) - {"base"}
306
+ if bad:
307
+ raise ValueError(f"unknown CodeBlock palette keys {sorted(bad)}; "
308
+ f"valid: {sorted(CODE_THEMES['light'])}")
309
+ base = str(palette_spec.get("base", "light"))
310
+ if base not in CODE_THEMES:
311
+ raise ValueError(f"palette base must be one of {tuple(CODE_THEMES)}")
312
+ self.palette_name = base
313
+ self.palette = dict(CODE_THEMES[base])
314
+ self.palette.update({k: v for k, v in palette_spec.items() if k != "base"})
315
+ src = source.expandtabs(int(tab_size))
316
+ if dedent:
317
+ src = textwrap.dedent(src).strip("\n")
318
+ self.source = src
319
+ self.lines: List[List[Token]] = tokenize(src, lang)
320
+ self._token_styles = {}
321
+ self._apply_token_colors(token_colors)
322
+ self.line_numbers = bool(line_numbers)
323
+ self.start_line = int(start_line)
324
+ if highlight_lines is None:
325
+ hl: Dict[int, str] = {}
326
+ elif isinstance(highlight_lines, Mapping):
327
+ hl = {int(k): str(v) for k, v in highlight_lines.items()}
328
+ else:
329
+ hl = {int(k): "" for k in highlight_lines}
330
+ for ln in hl:
331
+ self._check_line(ln)
332
+ self.highlight_lines = hl
333
+ self.font_size = font_size
334
+ self.title = title
335
+ self.annotations = {int(k): str(v) for k, v in (annotations or {}).items()}
336
+ for ln in self.annotations:
337
+ self._check_line(ln)
338
+ self.annotation_color = annotation_color
339
+ self.fill = fill
340
+ self.stroke = stroke
341
+ self.stroke_width = stroke_width
342
+ self.rounded = bool(rounded)
343
+ self.pad = pad
344
+ self.line_height = float(line_height)
345
+
346
+ def _style(self, kind):
347
+ return self._token_styles.get(kind, (
348
+ self.palette.get(kind, self.palette["plain"]),
349
+ kind in _BOLD.get(self.palette_name, set()), kind in _ITALIC))
350
+
351
+ def _apply_token_colors(self, spec):
352
+ from PIL import ImageColor
353
+
354
+ def is_color_like(color):
355
+ try:
356
+ ImageColor.getrgb(color)
357
+ return True
358
+ except ValueError:
359
+ return False
360
+
361
+ if spec is None:
362
+ return
363
+ if not isinstance(spec, Mapping):
364
+ raise ValueError("token_colors must be a mapping")
365
+ matches = []
366
+ for order, (key, value) in enumerate(spec.items()):
367
+ if not isinstance(key, str) or not key:
368
+ raise ValueError("token_colors keys must be nonempty strings")
369
+ options = value if isinstance(value, dict) else {"color": value}
370
+ if (not options or set(options) - {"color", "bold", "italic"}
371
+ or any(type(options[k]) is not bool for k in ("bold", "italic") if k in options)):
372
+ raise ValueError(f"invalid token_colors value for {key!r}: {value!r}")
373
+ color = options.get("color", "plain")
374
+ if not isinstance(color, str):
375
+ raise ValueError(f"token_colors color for {key!r} must be a token type or colour")
376
+ if color in TOKEN_TYPES:
377
+ style = self._style(color)
378
+ elif is_color_like(color):
379
+ style = (color, False, False)
380
+ else:
381
+ raise ValueError(f"unknown token type or invalid colour {color!r} for {key!r}; token types: {TOKEN_TYPES}")
382
+ kind = f"override:{order}"
383
+ self._token_styles[kind] = (style[0], options.get("bold", style[1]), options.get("italic", style[2]))
384
+ try:
385
+ pattern = re.compile(key[3:] if key.startswith("re:") else r"(?<!\w)" + re.escape(key) + r"(?!\w)")
386
+ except re.error as exc:
387
+ raise ValueError(f"invalid token_colors regex {key!r}: {exc}") from exc
388
+ for start in range(len(self.source)):
389
+ m = pattern.match(self.source, start)
390
+ if m and m.end() > start:
391
+ matches.append((start, m.end(), order, kind))
392
+ owners = [None] * len(self.source)
393
+ for start, end, order, kind in sorted(matches, key=lambda m: (-(m[1]-m[0]), m[2], m[0])):
394
+ if all(owner is None for owner in owners[start:end]):
395
+ owners[start:end] = [kind] * (end-start)
396
+ offset = 0
397
+ result = []
398
+ for line in self.lines:
399
+ pieces = []
400
+ for kind, text in line:
401
+ for char in text:
402
+ actual = owners[offset] or kind
403
+ if pieces and pieces[-1][0] == actual:
404
+ pieces[-1] = (actual, pieces[-1][1] + char)
405
+ else:
406
+ pieces.append((actual, char))
407
+ offset += 1
408
+ result.append(pieces)
409
+ offset += 1
410
+ self.lines = result
411
+
412
+ # ---- public helpers ------------------------------------------------------------
413
+ @property
414
+ def line_count(self) -> int:
415
+ """Number of source lines shown."""
416
+ return len(self.lines)
417
+
418
+ @property
419
+ def last_line(self) -> int:
420
+ """Number of the last line (``start_line + line_count - 1``)."""
421
+ return self.start_line + len(self.lines) - 1
422
+
423
+ def _check_line(self, n: int) -> int:
424
+ lo, hi = self.start_line, self.start_line + len(self.lines) - 1
425
+ if not lo <= int(n) <= hi:
426
+ raise IndexError(f"CodeBlock line {n} out of range {lo}..{hi}")
427
+ return int(n) - lo
428
+
429
+ def line_port(self, n: int, side: str = "right") -> Any:
430
+ """Port at line ``n`` (a displayed line number) for an :class:`~codefig.edges.Edge`.
431
+
432
+ ``side``: ``"right"`` / ``"left"`` — on the block border, vertically centred
433
+ on the line; ``"end"`` — just after the last character of the line (inside the
434
+ block; for short lines with arrows coming from the right).
435
+
436
+ Example: ``Edge(code.line_port(3), llm.port("left"))``.
437
+ """
438
+ from .edges import Port
439
+
440
+ if side not in ("left", "right", "end"):
441
+ raise ValueError("line_port side must be left|right|end")
442
+ self._check_line(n)
443
+ return Port(self, kind="row", side=side, index=int(n))
444
+
445
+ def line(self, n: int) -> CodeLineRef:
446
+ """Anchors of line ``n`` for constraints (``same_y(box, code.line(3))``)."""
447
+ self._check_line(n)
448
+ return CodeLineRef(self, n)
449
+
450
+ # ---- metrics --------------------------------------------------------------------
451
+ def _fs(self) -> float:
452
+ if self.font_size is not None:
453
+ return self.font_size
454
+ th = self.theme
455
+ if th.unit == 1.0:
456
+ return round(th.font_size * 0.85, 2)
457
+ return max(th.small_size, round(th.font_size * 0.85, 2))
458
+
459
+ def _tfs(self) -> float:
460
+ return self._fs() * 0.95 if self.theme.unit == 1.0 else self._fs()
461
+
462
+ def _lh(self) -> float:
463
+ return self._fs() * self.line_height
464
+
465
+ def _w(self, text: str, kind: str = "plain") -> float:
466
+ from .textutil import _plain_line_width
467
+
468
+ return _plain_line_width(text, self._fs(), True, *self._style(kind)[1:])
469
+
470
+ def _title_h(self) -> float:
471
+ return self._fs() * 1.9 if self.title else 0.0
472
+
473
+ def _gutter_w(self) -> float:
474
+ if not self.line_numbers:
475
+ return 0.0
476
+ digits = len(str(self.last_line))
477
+ return self._w("0" * digits) + self._fs() * 1.2
478
+
479
+ def _code_w(self) -> float:
480
+ return max((sum(self._w(t, _k) for _k, t in ln) for ln in self.lines), default=0.0)
481
+
482
+ def _ann_fs(self) -> float:
483
+ return self._tfs()
484
+
485
+ def _ann_w(self) -> float:
486
+ from .textutil import measure_text
487
+
488
+ if not self.annotations:
489
+ return 0.0
490
+ return max(measure_text(a, self._ann_fs())[0] for a in self.annotations.values()) + self._fs() * 1.2
491
+
492
+ def _measure(self) -> Tuple[float, float]:
493
+ w = self.pad * 2 + self._gutter_w() + self._code_w() + self._ann_w()
494
+ if self.title:
495
+ from .textutil import _plain_line_width
496
+
497
+ w = max(w, _plain_line_width(self.title, self._tfs(), False, True) + 2 * self.pad)
498
+ h = self._title_h() + self.pad * 2 + self._lh() * max(1, len(self.lines))
499
+ return w, h
500
+
501
+ def _line_band(self, n: int) -> Tuple[float, float]:
502
+ """(top y, height) of displayed line ``n`` in page coordinates."""
503
+ i = self._check_line(n)
504
+ g = self.geom
505
+ return g.y + self._title_h() + self.pad + i * self._lh(), self._lh()
506
+
507
+ def _line_end_x(self, n: int) -> float:
508
+ i = self._check_line(n)
509
+ g = self.geom
510
+ return g.x + self.pad + self._gutter_w() + sum(self._w(t, _k) for _k, t in self.lines[i])
511
+
512
+ def resolve_row_port(self, index: int, side: str) -> Tuple[float, float, str]:
513
+ y0, h = self._line_band(index)
514
+ cy = y0 + h / 2
515
+ g = self.geom
516
+ if side == "left":
517
+ return g.x, cy, "left"
518
+ if side == "end":
519
+ return self._line_end_x(index) + self._fs() * 0.4, cy, "right"
520
+ return g.right, cy, "right"
521
+
522
+ # ---- drawing -------------------------------------------------------------------
523
+ def draw(self, buf: List[str], ox: float = 0.0, oy: float = 0.0) -> None:
524
+ from .render_svg import draw_line, draw_rect, draw_text
525
+ from .textutil import xml_escape
526
+
527
+ g = self.geom
528
+ x, y = g.x + ox, g.y + oy
529
+ pal = self.palette
530
+ fs = self._fs()
531
+ lh = self._lh()
532
+ r = self.theme.L(4.0) if self.rounded else 0.0
533
+ stroke = self._border_stroke(self.stroke or pal["border"])
534
+ draw_rect(buf, x, y, g.w, g.h, fill=self.fill or pal["bg"], stroke=stroke,
535
+ stroke_width=self.stroke_width, radius=r, dasharray=self._dash(self.stroke_width))
536
+ th = self._title_h()
537
+ if self.title:
538
+ tfs = self._tfs()
539
+ draw_rect(buf, x + self.stroke_width / 2, y + self.stroke_width / 2,
540
+ g.w - self.stroke_width, th - self.stroke_width / 2, fill=pal["title_bg"],
541
+ stroke="none", stroke_width=0, radius=max(0.0, r - 1))
542
+ draw_line(buf, x, y + th, x + g.w, y + th, stroke=pal["border"], stroke_width=self.theme.SW(0.8))
543
+ # literal text (file names such as ``lotus_query.py`` keep their underscores)
544
+ from .textutil import latin_font_family_css
545
+
546
+ buf.append(f'<text x="{x + self.pad:.2f}" y="{y + th / 2 + tfs * 0.36:.2f}" '
547
+ f'font-family="{xml_escape(latin_font_family_css(False))}" '
548
+ f'font-size="{tfs:.2f}" font-weight="bold" fill="{pal["title"]}">'
549
+ f'{xml_escape(self.title)}</text>')
550
+ top = y + th + self.pad
551
+ gw = self._gutter_w()
552
+ code_x = x + self.pad + gw
553
+ # highlighted-line bands (full inner width)
554
+ for ln, col in sorted(self.highlight_lines.items()):
555
+ i = ln - self.start_line
556
+ draw_rect(buf, x + self.stroke_width / 2, top + i * lh, g.w - self.stroke_width, lh,
557
+ fill=col or pal["highlight"], stroke="none", stroke_width=0)
558
+ fam = self.theme.mono_family
559
+ bold = _BOLD.get(self.palette_name, set())
560
+ base = fs * 0.36 # baseline below the line centre
561
+ if self.line_numbers:
562
+ sep_x = x + self.pad + gw - fs * 0.6
563
+ draw_line(buf, sep_x, top, sep_x, top + lh * len(self.lines), stroke=pal["border"],
564
+ stroke_width=self.theme.SW(0.6))
565
+ for i in range(len(self.lines)):
566
+ label = str(self.start_line + i)
567
+ tw = self._w(label)
568
+ buf.append(
569
+ f'<text x="{sep_x - fs * 0.5 - tw:.2f}" y="{top + i * lh + lh / 2 + base:.2f}" '
570
+ f'font-family="{fam}" font-size="{fs:.2f}" fill="{pal["gutter"]}">'
571
+ f"{label}</text>")
572
+ for i, line in enumerate(self.lines):
573
+ if not line:
574
+ continue
575
+ cy = top + i * lh + lh / 2 + base
576
+ cx = code_x
577
+ spans: List[str] = []
578
+ for kind, text in line:
579
+ color, weight, italic = self._style(kind)
580
+ for m in re.finditer(r"\S+|\s+", text):
581
+ chunk = m.group()
582
+ if not chunk.isspace():
583
+ attrs = f'x="{cx + self._w(text[:m.start()], kind):.2f}" fill="{color}"'
584
+ if weight:
585
+ attrs += ' font-weight="bold"'
586
+ if italic:
587
+ attrs += ' font-style="italic"'
588
+ spans.append(f"<tspan {attrs}>{xml_escape(chunk)}</tspan>")
589
+ cx += self._w(text, kind)
590
+ buf.append(f'<text y="{cy:.2f}" font-family="{fam}" font-size="{fs:.2f}">'
591
+ + "".join(spans) + "</text>")
592
+ if self.annotations:
593
+ ax = code_x + self._code_w() + fs * 1.2
594
+ afs = self._ann_fs()
595
+ col = self.annotation_color or self.theme.highlight
596
+ for ln, text in sorted(self.annotations.items()):
597
+ i = ln - self.start_line
598
+ draw_text(buf, text, ax, top + i * lh + lh / 2 + afs * 0.36, font_size=afs,
599
+ fill=col)
600
+
601
+
602
+ __all__ = ["CodeBlock", "CodeLineRef", "tokenize", "CODE_THEMES", "CODE_LANGS", "TOKEN_TYPES"]