simit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,89 @@
1
+ """Visualization routing: pick the skill that should realize a description.
2
+
3
+ The router prompt is assembled from the active skills: each contributes its
4
+ category definition (``route_hint``) and example requests. With exactly the
5
+ built-in skills active, the assembled prompt is identical to the original
6
+ SIMIT router prompt (``prompts.CLASSIFY_PROMPT``).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from typing import Sequence
13
+
14
+ from . import prompts as P
15
+
16
+ _NUMBER_WORDS = ["zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten",
17
+ "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen",
18
+ "nineteen", "twenty", "twenty-one", "twenty-two", "twenty-three", "twenty-four",
19
+ "twenty-five", "twenty-six", "twenty-seven", "twenty-eight", "twenty-nine", "thirty"]
20
+
21
+
22
+ def _parse_builtin_prompt(text: str):
23
+ """Split CLASSIFY_PROMPT into (definitions{LABEL: paragraph}, label order,
24
+ few-shot [(request, LABEL)])."""
25
+ head, rest = text.split("\n\n", 1)
26
+ defs_part, tail = rest.split("\n\nAnswer with exactly one word:", 1)
27
+ definitions, order = {}, []
28
+ for para in defs_part.split("\n\n"):
29
+ label, body = para.split(":", 1)
30
+ definitions[label] = body.strip()
31
+ order.append(label)
32
+ shots = re.findall(r'Request: "(.*?)"\nAnswer: (\w+)\n', tail, re.DOTALL)
33
+ shots = [(r, a) for r, a in shots if r != "{request}"]
34
+ return head, definitions, order, shots
35
+
36
+
37
+ _HEAD, BUILTIN_DEFINITIONS, BUILTIN_ORDER, BUILTIN_SHOTS = _parse_builtin_prompt(P.CLASSIFY_PROMPT)
38
+
39
+
40
+ def _number_word(n: int) -> str:
41
+ return _NUMBER_WORDS[n] if n < len(_NUMBER_WORDS) else str(n)
42
+
43
+
44
+ def build_router_prompt(skills: Sequence) -> str:
45
+ """Router prompt (with a ``{request}`` placeholder) for ``skills``."""
46
+ labels = [s.label for s in skills]
47
+ head = _HEAD.replace("seventeen", _number_word(len(labels)))
48
+ defs = "\n\n".join(f"{s.label}: {_wrap_hint(s)}" for s in skills)
49
+ if len(labels) == 1:
50
+ answer = f"Answer with exactly one word: {labels[0]}."
51
+ else:
52
+ answer = "Answer with exactly one word: " + ", ".join(labels[:-1]) + f", or {labels[-1]}."
53
+ active = set(labels)
54
+ shots = [(req, lab) for req, lab in BUILTIN_SHOTS if lab in active]
55
+ for s in skills:
56
+ if s.label not in BUILTIN_DEFINITIONS:
57
+ shots += [(req, s.label) for req in s.get_route_examples()]
58
+ body = "".join(f'Request: "{req}"\nAnswer: {lab}\n\n' for req, lab in shots)
59
+ prompt = f"{head}\n\n{defs}\n\n{answer}\n\n{body}" + 'Request: "{request}"\nAnswer:'
60
+ return prompt
61
+
62
+
63
+ def _wrap_hint(skill) -> str:
64
+ hint = BUILTIN_DEFINITIONS.get(skill.label) if skill.route_hint == "" else skill.route_hint
65
+ return (hint or skill.name.replace("_", " ")).strip()
66
+
67
+
68
+ def parse_route(answer: str, skills: Sequence, default: str) -> str:
69
+ """Map the router's free-text answer to a skill name (longest label first,
70
+ so HTML_COMPOSITE beats HTML)."""
71
+ answer = answer.strip().upper()
72
+ for skill in sorted(skills, key=lambda s: len(s.label), reverse=True):
73
+ if skill.label in answer:
74
+ return skill.name
75
+ return default
76
+
77
+
78
+ def pre_route(request: str, skills: Sequence) -> str | None:
79
+ """Deterministic routing rules that bypass the model (``Skill.route_pattern``)."""
80
+ ordered = sorted(skills, key=lambda s: -getattr(s, "route_priority", 0))
81
+ for skill in ordered:
82
+ pattern = getattr(skill, "route_pattern", None)
83
+ if pattern is None or not pattern.search(request):
84
+ continue
85
+ exclude = getattr(skill, "route_exclude_pattern", None)
86
+ if exclude is not None and exclude.search(request):
87
+ continue
88
+ return skill.name
89
+ return None
@@ -0,0 +1,188 @@
1
+ """Isolated execution of model-written matplotlib code (the FIGURE skill)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ from pathlib import Path
11
+
12
+ from PIL import Image
13
+
14
+ from ..utils import CJK_RE
15
+ from .helpers import GEOMETRY_HELPERS_SRC, TEXT_OVERLAP_FIX_SRC
16
+
17
+ CODE_BLOCK_RE = re.compile(r"```(?:python)?\s*\n(.*?)```", re.DOTALL)
18
+
19
+ # The model is only ever asked for matplotlib/numpy code; anything touching
20
+ # the OS is refused rather than executed.
21
+ DENYLIST = (
22
+ "import os", "import sys", "import subprocess", "import socket",
23
+ "import shutil", "__import__", "eval(", "exec(", "open(", "os.", "sys.",
24
+ )
25
+
26
+ MAX_DIM = 2000
27
+ MAX_ASPECT_RATIO = 9.0
28
+ TIMEOUT_S = 20
29
+
30
+
31
+ def extract_code(text: str) -> str | None:
32
+ m = CODE_BLOCK_RE.search(text)
33
+ return m.group(1).strip() if m else None
34
+
35
+
36
+ def is_safe(code: str) -> bool:
37
+ return not any(pattern in code for pattern in DENYLIST)
38
+
39
+
40
+ def find_non_ascii_issue(code: str) -> str | None:
41
+ if CJK_RE.search(code):
42
+ return (
43
+ "The code mixes Chinese characters into Python identifiers/calls "
44
+ "(e.g. `ax.plot共同点列表,...`), which is invalid syntax. Rewrite the "
45
+ "entire script using only English words and standard ASCII "
46
+ "characters for every variable name, function call, and argument."
47
+ )
48
+ return None
49
+
50
+
51
+ def find_known_api_issues(code: str) -> str | None:
52
+ if "boxplot(" in code and "tick_labels" not in code and re.search(r"\blabels\s*=", code):
53
+ return (
54
+ "ax.boxplot's label argument is called tick_labels in this matplotlib "
55
+ "version, not labels. Replace labels= with tick_labels= in the boxplot() call."
56
+ )
57
+ if re.search(r"\bArc\(", code) and re.search(r"\b(angle1|angle2|start_angle|end_angle)\s*=", code):
58
+ return (
59
+ "matplotlib's Arc patch takes theta1 and theta2 for its sweep angles -- "
60
+ "angle1/angle2/start_angle/end_angle are not real Arc parameters. Either fix the "
61
+ "Arc(...) call to use theta1=/theta2=, or better, use the pre-defined "
62
+ "angle_arc(ax, vertex, theta1, theta2, radius=..., label=...) helper instead of "
63
+ "constructing Arc directly."
64
+ )
65
+ return None
66
+
67
+
68
+ _ZYGOTE_SRC = r"""
69
+ import os, sys, signal, struct, pickle, time, traceback
70
+ import matplotlib
71
+ matplotlib.use("Agg")
72
+ import matplotlib.pyplot, numpy # noqa: F401 (preloaded: forked jobs start warm)
73
+ rd, wr = os.fdopen(0, "rb"), os.fdopen(1, "wb")
74
+ def send(obj):
75
+ data = pickle.dumps(obj); wr.write(struct.pack("<I", len(data)) + data); wr.flush()
76
+ while True:
77
+ head = rd.read(4)
78
+ if len(head) < 4:
79
+ break
80
+ script, workdir, timeout = pickle.loads(rd.read(struct.unpack("<I", head)[0]))
81
+ err_path = os.path.join(workdir, "stderr.txt")
82
+ pid = os.fork()
83
+ if pid == 0:
84
+ try:
85
+ os.chdir(workdir)
86
+ fd = os.open(err_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC)
87
+ os.dup2(fd, 2)
88
+ code = compile(script, "script.py", "exec")
89
+ exec(code, {"__name__": "__main__"})
90
+ os._exit(0)
91
+ except SystemExit as e:
92
+ os._exit(e.code if isinstance(e.code, int) else 1)
93
+ except BaseException:
94
+ traceback.print_exc()
95
+ sys.stderr.flush()
96
+ os._exit(1)
97
+ deadline = time.time() + timeout
98
+ status = None
99
+ while time.time() < deadline:
100
+ done, st = os.waitpid(pid, os.WNOHANG)
101
+ if done:
102
+ status = st
103
+ break
104
+ time.sleep(0.01)
105
+ if status is None:
106
+ os.kill(pid, signal.SIGKILL)
107
+ os.waitpid(pid, 0)
108
+ send((False, f"Execution timed out after {timeout}s"))
109
+ continue
110
+ err = ""
111
+ if os.path.exists(err_path):
112
+ with open(err_path) as f:
113
+ err = f.read()[-2000:]
114
+ send((os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0, err))
115
+ """
116
+
117
+
118
+ class _Zygote:
119
+ """A warm interpreter (matplotlib/numpy imported) that forks one child per
120
+ script, so each FIGURE render skips the ~1.5s interpreter+import start."""
121
+
122
+ def __init__(self):
123
+ self.proc = None
124
+
125
+ def _start(self):
126
+ env = {"MPLBACKEND": "Agg", "PATH": "/usr/bin:/bin"}
127
+ if "HOME" in os.environ:
128
+ env["HOME"] = os.environ["HOME"]
129
+ self.proc = subprocess.Popen([sys.executable, "-c", _ZYGOTE_SRC], stdin=subprocess.PIPE,
130
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=env)
131
+
132
+ def run(self, script: str, workdir: str, timeout: int) -> tuple[bool, str]:
133
+ import pickle
134
+ import struct
135
+ if self.proc is None or self.proc.poll() is not None:
136
+ self._start()
137
+ data = pickle.dumps((script, workdir, timeout))
138
+ self.proc.stdin.write(struct.pack("<I", len(data)) + data)
139
+ self.proc.stdin.flush()
140
+ head = self.proc.stdout.read(4)
141
+ if len(head) < 4:
142
+ self.proc = None
143
+ raise RuntimeError("figure sandbox died")
144
+ return pickle.loads(self.proc.stdout.read(struct.unpack("<I", head)[0]))
145
+
146
+
147
+ _ZYGOTE = _Zygote() if hasattr(os, "fork") and sys.platform.startswith("linux") else None
148
+
149
+
150
+ def run_script(code: str) -> tuple[Image.Image | None, str]:
151
+ """Run ``code`` isolated from this process; it must save ``output.png``.
152
+ Returns (image, "") on success or (None, error_text)."""
153
+ with tempfile.TemporaryDirectory(prefix="simit_fig_") as tmp:
154
+ workdir = Path(tmp)
155
+ script = GEOMETRY_HELPERS_SRC + "\n" + TEXT_OVERLAP_FIX_SRC + "\n" + code
156
+ ok, err = None, ""
157
+ if _ZYGOTE is not None:
158
+ try:
159
+ ok, err = _ZYGOTE.run(script, tmp, TIMEOUT_S)
160
+ except Exception:
161
+ ok = None
162
+ if ok is None: # no fork available (or the zygote died): fresh interpreter
163
+ (workdir / "script.py").write_text(script)
164
+ env = {"MPLBACKEND": "Agg", "PATH": "/usr/bin:/bin", "MPLCONFIGDIR": tmp}
165
+ if "HOME" in os.environ:
166
+ env["HOME"] = os.environ["HOME"]
167
+ try:
168
+ proc = subprocess.run([sys.executable, "script.py"], cwd=workdir, capture_output=True,
169
+ text=True, timeout=TIMEOUT_S, env=env)
170
+ ok, err = proc.returncode == 0, proc.stderr[-2000:]
171
+ except subprocess.TimeoutExpired:
172
+ ok, err = False, f"Execution timed out after {TIMEOUT_S}s"
173
+ if not ok:
174
+ return None, err or "script failed"
175
+ png = workdir / "output.png"
176
+ if not png.exists():
177
+ return None, "Script exited cleanly but did not produce output.png"
178
+ image = Image.open(png)
179
+ image.load()
180
+ w, h = image.size
181
+ if w > MAX_DIM or h > MAX_DIM or max(w, h) / max(1, min(w, h)) > MAX_ASPECT_RATIO:
182
+ return None, (
183
+ f"Rendered image has pathological dimensions {w}x{h}px. This usually "
184
+ "means a shape was added with ax.add_patch(...) without also calling "
185
+ "ax.set_xlim(...)/ax.set_ylim(...) explicitly, or bbox_inches='tight' picked up "
186
+ "an artist placed far outside the intended view. Fix the axis limits."
187
+ )
188
+ return image, ""
simit/skills/web.py ADDED
@@ -0,0 +1,253 @@
1
+ """Browser-backed rendering (HTML, Mermaid) through Playwright's Chromium.
2
+
3
+ One headless browser is kept alive per process, so a render costs one page
4
+ load instead of a browser launch. The Chromium binary is downloaded on first
5
+ use (``python -m playwright install chromium``) -- no Node.js, Mermaid CLI,
6
+ or system Chromium is needed: Mermaid runs from the ``mermaid.min.js`` bundled
7
+ with this package. Network access from rendered pages is blocked.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import re
15
+ import subprocess
16
+ import sys
17
+ import threading
18
+ from pathlib import Path
19
+
20
+ from .base import SpecError
21
+
22
+ _ASSETS = Path(__file__).parent / "assets"
23
+ _MERMAID_JS = _ASSETS / "mermaid.min.js"
24
+
25
+ _SCRIPT_RE = re.compile(r"<script[\s>]", re.IGNORECASE)
26
+ _HANDLER_RE = re.compile(r"\bon\w+\s*=", re.IGNORECASE)
27
+
28
+
29
+ def is_safe_markup(code: str) -> bool:
30
+ """Model-written HTML/SVG must not run scripts."""
31
+ return not (_SCRIPT_RE.search(code) or _HANDLER_RE.search(code))
32
+
33
+
34
+ _LAUNCH_ARGS = ["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"]
35
+ _SYSTEM_BROWSERS = ("chromium", "chromium-browser", "google-chrome", "google-chrome-stable", "chrome")
36
+
37
+
38
+ def _system_browser() -> str | None:
39
+ if os.environ.get("SIMIT_CHROMIUM"):
40
+ return os.environ["SIMIT_CHROMIUM"]
41
+ import shutil
42
+
43
+ for name in _SYSTEM_BROWSERS:
44
+ path = shutil.which(name)
45
+ if path:
46
+ return path
47
+ return None
48
+
49
+
50
+ def _install_chromium():
51
+ """Download Playwright's headless Chromium once, serialized across processes."""
52
+ import fcntl
53
+ import tempfile
54
+
55
+ lock_path = Path(tempfile.gettempdir()) / "simit_playwright_install.lock"
56
+ with open(lock_path, "w") as lock:
57
+ fcntl.flock(lock, fcntl.LOCK_EX)
58
+ print("[simit] downloading headless Chromium for HTML/Mermaid rendering (one-time)...",
59
+ file=sys.stderr, flush=True)
60
+ r = subprocess.run([sys.executable, "-m", "playwright", "install", "chromium-headless-shell"],
61
+ check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=900)
62
+ if r.returncode != 0: # Playwright's (Node) downloader fails on some networks
63
+ _download_headless_shell()
64
+
65
+
66
+ def _download_headless_shell():
67
+ """Fetch the build Playwright expects with plain Python and unpack it where
68
+ Playwright looks for it."""
69
+ import shutil
70
+ import tempfile
71
+ import urllib.request
72
+ import zipfile
73
+
74
+ out = subprocess.run([sys.executable, "-m", "playwright", "install", "--dry-run", "chromium-headless-shell"],
75
+ capture_output=True, text=True, timeout=120).stdout
76
+ block = out.split("\n\n")[0]
77
+ loc = re.search(r"Install location:\s*(.+)", block)
78
+ urls = re.findall(r"Download (?:url|fallback \d+):\s*(\S+)", block)
79
+ if not loc or not urls:
80
+ return
81
+ dest = Path(loc.group(1).strip())
82
+ with tempfile.TemporaryDirectory() as tmp:
83
+ archive = Path(tmp) / "browser.zip"
84
+ for url in urls:
85
+ try:
86
+ with urllib.request.urlopen(url, timeout=60) as resp, open(archive, "wb") as f:
87
+ shutil.copyfileobj(resp, f)
88
+ break
89
+ except Exception:
90
+ continue
91
+ else:
92
+ return
93
+ staging = dest.with_name(dest.name + ".partial")
94
+ shutil.rmtree(staging, ignore_errors=True)
95
+ with zipfile.ZipFile(archive) as z:
96
+ for info in z.infolist():
97
+ path = z.extract(info, staging)
98
+ mode = (info.external_attr >> 16) & 0o777
99
+ if mode:
100
+ os.chmod(path, mode)
101
+ shutil.rmtree(dest, ignore_errors=True)
102
+ dest.parent.mkdir(parents=True, exist_ok=True)
103
+ os.replace(staging, dest)
104
+ (dest / "INSTALLATION_COMPLETE").touch()
105
+
106
+
107
+ class _Browser:
108
+ def __init__(self):
109
+ self._pw = None
110
+ self._browser = None
111
+ self._owner = None
112
+
113
+ def _launch(self):
114
+ """Playwright's own Chromium, else a system Chrome/Chromium, else
115
+ download Playwright's Chromium and retry."""
116
+ from playwright.sync_api import sync_playwright
117
+
118
+ if self._pw is not None: # a previous launch failed or another thread owned it
119
+ try:
120
+ self._pw.stop()
121
+ except Exception:
122
+ pass
123
+ self._pw = sync_playwright().start()
124
+ errors = []
125
+ for attempt in ("bundled", "system", "install"):
126
+ try:
127
+ if attempt == "system":
128
+ exe = _system_browser()
129
+ if exe is None:
130
+ continue
131
+ self._browser = self._pw.chromium.launch(executable_path=exe, args=_LAUNCH_ARGS)
132
+ else:
133
+ if attempt == "install":
134
+ _install_chromium()
135
+ self._browser = self._pw.chromium.launch(args=_LAUNCH_ARGS)
136
+ return
137
+ except Exception as e:
138
+ errors.append(f"{attempt}: {str(e).splitlines()[0]}")
139
+ raise SpecError("no usable Chromium for HTML/Mermaid rendering (" + "; ".join(errors) +
140
+ "). Run `python -m playwright install chromium` or set SIMIT_CHROMIUM.")
141
+
142
+ def page(self, width: int, height: int):
143
+ # Playwright's sync API is bound to the thread that started it.
144
+ if self._browser is None or self._owner != (os.getpid(), threading.get_ident()):
145
+ self._launch()
146
+ self._owner = (os.getpid(), threading.get_ident())
147
+ page = self._browser.new_page(viewport={"width": width, "height": height})
148
+ page.route("**/*", lambda route: route.abort()
149
+ if route.request.url.startswith(("http://", "https://")) else route.continue_())
150
+ return page
151
+
152
+
153
+ _BROWSER = _Browser()
154
+
155
+
156
+ def render_html(html: str, width: int = 900, height: int = 700) -> bytes:
157
+ """Screenshot of the ``width`` x ``height`` viewport (PNG bytes)."""
158
+ if not is_safe_markup(html):
159
+ raise SpecError("HTML contains script elements or event handlers")
160
+ page = _BROWSER.page(width, height)
161
+ try:
162
+ page.set_content(html, wait_until="load", timeout=30_000)
163
+ return page.screenshot(timeout=30_000)
164
+ finally:
165
+ page.close()
166
+
167
+
168
+ _MERMAID_PAGE = """<!DOCTYPE html><html><head><meta charset="utf-8">
169
+ <style>body{margin:0;background:white;} #out{width:100%;background:white;}</style>
170
+ </head><body><div id="out"></div></body></html>"""
171
+
172
+ _MERMAID_RENDER_JS = """async (code) => {
173
+ try {
174
+ mermaid.initialize({startOnLoad: false, securityLevel: 'strict', theme: 'default'});
175
+ const {svg} = await mermaid.render('simit_graph', code);
176
+ const out = document.getElementById('out');
177
+ out.innerHTML = svg;
178
+ return {ok: true};
179
+ } catch (e) {
180
+ return {ok: false, error: String((e && e.message) || e)};
181
+ }
182
+ }"""
183
+
184
+
185
+ def render_mermaid(code: str, width: int = 900) -> bytes:
186
+ """Render Mermaid code to PNG (same page width as ``mmdc -w 900``)."""
187
+ page = _BROWSER.page(width, 600)
188
+ try:
189
+ page.set_content(_MERMAID_PAGE, wait_until="load")
190
+ page.add_script_tag(path=str(_MERMAID_JS))
191
+ result = page.evaluate(_MERMAID_RENDER_JS, code)
192
+ if not result.get("ok"):
193
+ msg = result.get("error", "Mermaid could not render this diagram")
194
+ raise SpecError(msg.strip().splitlines()[0][:400] if msg.strip() else "Mermaid syntax error")
195
+ # Like mmdc: screenshot the SVG's bounding box (mermaid caps its width
196
+ # at the natural size, and the page width caps it at ``width``).
197
+ element = page.query_selector("#out svg")
198
+ if element is None:
199
+ raise SpecError("Mermaid produced no diagram")
200
+ return element.screenshot(timeout=30_000)
201
+ finally:
202
+ page.close()
203
+
204
+
205
+ def render_svg(svg_code: str, output_size: int = 600) -> bytes:
206
+ """Rasterize SVG onto a white ``output_size`` square, scaled to fit and
207
+ centred (as cairosvg's output_width/output_height did), via resvg in
208
+ vl-convert -- no system cairo needed."""
209
+ import io
210
+
211
+ import vl_convert
212
+ from PIL import Image
213
+
214
+ if not is_safe_markup(svg_code):
215
+ raise SpecError("SVG contains script elements or event handlers")
216
+ m = re.search(r"<svg\b[^>]*>", svg_code, re.IGNORECASE | re.DOTALL)
217
+ if not m:
218
+ raise SpecError("no <svg> element found")
219
+ w, h = _svg_size(m.group(0))
220
+ scale = output_size / max(w, h) if w and h else 1.0
221
+ try:
222
+ png = vl_convert.svg_to_png(svg_code, scale=scale)
223
+ except Exception as e: # malformed XML etc.
224
+ raise SpecError(f"SVG could not be rendered: {str(e).splitlines()[0][:300]}") from None
225
+ drawing = Image.open(io.BytesIO(png)).convert("RGBA")
226
+ drawing.thumbnail((output_size, output_size))
227
+ canvas = Image.new("RGB", (output_size, output_size), "white")
228
+ canvas.paste(drawing, ((output_size - drawing.width) // 2, (output_size - drawing.height) // 2), drawing)
229
+ buf = io.BytesIO()
230
+ canvas.save(buf, format="PNG")
231
+ return buf.getvalue()
232
+
233
+
234
+ def _svg_size(tag: str) -> tuple[float, float]:
235
+ def num(attr):
236
+ m = re.search(rf'\b{attr}\s*=\s*["\']\s*([\d.]+)', tag)
237
+ return float(m.group(1)) if m else 0.0
238
+
239
+ w, h = num("width"), num("height")
240
+ if not (w and h):
241
+ vb = re.search(r'viewBox\s*=\s*["\']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)[\s,]+([\d.]+)', tag)
242
+ if vb:
243
+ w, h = float(vb.group(1)), float(vb.group(2))
244
+ return w, h
245
+
246
+
247
+ def render_vegalite(spec: dict) -> bytes:
248
+ import vl_convert
249
+
250
+ try:
251
+ return vl_convert.vegalite_to_png(vl_spec=json.dumps(spec), scale=2)
252
+ except Exception as e:
253
+ raise SpecError(str(e).strip().splitlines()[0][:400] or "invalid Vega-Lite spec") from None