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/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"]
|