pac-vector 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.
pac_vector/preview.py ADDED
@@ -0,0 +1,275 @@
1
+ """Live preview sidecar: a tiny HTTP server inside the MCP process.
2
+
3
+ Started lazily on the first doc_create, bound to 127.0.0.1 on an OS-picked port
4
+ (PAC_VECTOR_PORT pins one). Browsers subscribe to /events (Server-Sent Events) and
5
+ get the full SVG of a document after every edit. It dies with the process.
6
+ Nothing here may write to stdout — that is the JSON-RPC pipe.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import os
14
+ import queue
15
+ import threading
16
+ import webbrowser
17
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
18
+ from urllib.parse import unquote, urlparse
19
+
20
+ log = logging.getLogger("pac_vector.preview")
21
+
22
+
23
+ class Preview:
24
+ def __init__(self, store, host: str = "127.0.0.1", port: int = 0):
25
+ self.store = store
26
+ self.host = host
27
+ self.port = port
28
+ self.url: str | None = None
29
+ self._srv = None
30
+ self._clients: set[queue.Queue] = set()
31
+ self._lock = threading.Lock()
32
+
33
+ def start(self) -> str:
34
+ with self._lock:
35
+ if self._srv:
36
+ return self.url
37
+ handler = _handler(self)
38
+ try:
39
+ srv = ThreadingHTTPServer((self.host, self.port), handler)
40
+ except OSError:
41
+ if not self.port:
42
+ raise
43
+ log.warning("port %s busy, falling back to a free port", self.port)
44
+ srv = ThreadingHTTPServer((self.host, 0), handler)
45
+ srv.daemon_threads = True
46
+ threading.Thread(target=srv.serve_forever, name="pac-vector-preview", daemon=True).start()
47
+ self._srv = srv
48
+ self.url = f"http://{self.host}:{srv.server_address[1]}/"
49
+ log.info("preview at %s", self.url)
50
+ if os.environ.get("PAC_VECTOR_OPEN_BROWSER") == "1":
51
+ webbrowser.open(self.url)
52
+ return self.url
53
+
54
+ def publish(self, msg: dict) -> None:
55
+ if not self._srv:
56
+ return
57
+ data = json.dumps(msg)
58
+ with self._lock:
59
+ clients = list(self._clients)
60
+ for q in clients:
61
+ try:
62
+ q.put_nowait(data)
63
+ except queue.Full: # slow tab: it skips a frame, the next one is a full SVG anyway
64
+ pass
65
+
66
+ def _register(self, q):
67
+ with self._lock:
68
+ self._clients.add(q)
69
+
70
+ def _unregister(self, q):
71
+ with self._lock:
72
+ self._clients.discard(q)
73
+
74
+
75
+ def _handler(pv: Preview):
76
+ class Handler(BaseHTTPRequestHandler):
77
+ server_version = "pac-vector"
78
+
79
+ def log_message(self, fmt, *args): # default goes to stderr; keep it quiet
80
+ log.debug(fmt, *args)
81
+
82
+ def do_GET(self):
83
+ path = urlparse(self.path).path
84
+ if path == "/":
85
+ self._send(200, "text/html; charset=utf-8", PAGE.encode("utf-8"))
86
+ elif path == "/events":
87
+ self._events()
88
+ elif path.startswith("/svg/"):
89
+ doc = pv.store.docs.get(unquote(path[5:]))
90
+ if not doc:
91
+ self._send(404, "text/plain", b"no such document")
92
+ return
93
+ with pv.store.lock:
94
+ from lxml import etree
95
+
96
+ body = etree.tostring(doc.root, encoding="utf-8")
97
+ self._send(200, "image/svg+xml", body)
98
+ else:
99
+ self._send(404, "text/plain", b"not found")
100
+
101
+ def _send(self, code, ctype, body: bytes):
102
+ self.send_response(code)
103
+ self.send_header("Content-Type", ctype)
104
+ self.send_header("Content-Length", str(len(body)))
105
+ self.send_header("Cache-Control", "no-store")
106
+ self.end_headers()
107
+ self.wfile.write(body)
108
+
109
+ def _write(self, data: str):
110
+ self.wfile.write(f"data: {data}\n\n".encode("utf-8"))
111
+ self.wfile.flush()
112
+
113
+ def _events(self):
114
+ self.send_response(200)
115
+ self.send_header("Content-Type", "text/event-stream")
116
+ self.send_header("Cache-Control", "no-store")
117
+ self.end_headers()
118
+ q: queue.Queue = queue.Queue(maxsize=64)
119
+ pv._register(q) # register first: a racing edit arrives twice, never zero times
120
+ try:
121
+ with pv.store.lock:
122
+ initial = [d.payload() for d in pv.store.docs.values()]
123
+ for p in initial:
124
+ self._write(json.dumps(p))
125
+ while True:
126
+ try:
127
+ self._write(q.get(timeout=15))
128
+ except queue.Empty:
129
+ self.wfile.write(b": ping\n\n")
130
+ self.wfile.flush()
131
+ except OSError: # tab closed
132
+ pass
133
+ finally:
134
+ pv._unregister(q)
135
+
136
+ return Handler
137
+
138
+
139
+ PAGE = r"""<!doctype html>
140
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
141
+ <title>pac-vector preview</title>
142
+ <style>
143
+ :root{--bg:#1e1f22;--panel:#2b2d31;--line:#3a3c42;--fg:#e6e6e6;--mut:#9aa0a6;--acc:#7c9cff;--hl:#ff3ec8}
144
+ *{box-sizing:border-box}
145
+ body{margin:0;background:var(--bg);color:var(--fg);font:13px/1.4 system-ui,"Segoe UI",sans-serif;height:100vh;display:flex;flex-direction:column}
146
+ #tabs{display:flex;gap:2px;padding:6px 8px 0;background:var(--panel);border-bottom:1px solid var(--line);overflow-x:auto;min-height:33px}
147
+ .tab{padding:6px 12px;border-radius:6px 6px 0 0;cursor:pointer;color:var(--mut);white-space:nowrap;border:1px solid transparent;border-bottom:none}
148
+ .tab.on{background:var(--bg);color:var(--fg);border-color:var(--line)}
149
+ .tab .dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--acc);margin-left:6px;visibility:hidden}
150
+ .tab.flash .dot{visibility:visible}
151
+ #bar{display:flex;gap:12px;align-items:center;padding:6px 12px;border-bottom:1px solid var(--line);color:var(--mut);font-size:12px}
152
+ #bar button{background:var(--panel);color:var(--fg);border:1px solid var(--line);border-radius:4px;padding:2px 10px;cursor:pointer}
153
+ #bar a{color:var(--acc)}
154
+ #info{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
155
+ #wrap{flex:1;overflow:auto;display:grid;place-items:center;padding:24px}
156
+ #page{position:relative;box-shadow:0 0 0 1px var(--line),0 8px 30px #0008;background-color:#fff;
157
+ background-image:conic-gradient(#e6e6e6 25%,#fff 0 50%,#e6e6e6 0 75%,#fff 0);background-size:16px 16px}
158
+ #page.bg{background-image:none}
159
+ #page>svg{display:block}
160
+ #ov{position:absolute;inset:0;pointer-events:none}
161
+ .box{position:absolute;border:1.5px dashed var(--hl);transition:opacity .8s}
162
+ .hov{position:absolute;border:1px solid var(--acc)}
163
+ #status{padding:4px 12px;border-top:1px solid var(--line);color:var(--mut);font:12px ui-monospace,Consolas,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
164
+ #empty{color:var(--mut)}
165
+ </style></head><body>
166
+ <div id="tabs"></div>
167
+ <div id="bar"><span id="info"></span><button id="zoom">fit</button><a id="raw" target="_blank">raw svg</a></div>
168
+ <div id="wrap"><div id="empty">No documents open — call doc_create.</div><div id="page" hidden><div id="ov"></div></div></div>
169
+ <div id="status">connecting…</div>
170
+ <script>
171
+ const INK = "http://www.inkscape.org/namespaces/inkscape";
172
+ const docs = {}; let active = decodeURIComponent(location.hash.slice(1)) || null, fit = true;
173
+ const $ = id => document.getElementById(id);
174
+ const esc = s => String(s).replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
175
+ const svgEl = () => $("page").querySelector(":scope > svg");
176
+
177
+ function tabs() {
178
+ const t = $("tabs"); t.innerHTML = "";
179
+ for (const id in docs) {
180
+ const el = document.createElement("div");
181
+ el.className = "tab" + (id === active ? " on" : "");
182
+ el.title = id; el.innerHTML = esc(docs[id].name) + "<span class=dot></span>";
183
+ el.onclick = () => { location.hash = id; };
184
+ t.appendChild(el);
185
+ }
186
+ }
187
+ function show() {
188
+ const d = docs[active];
189
+ $("empty").hidden = !!d; $("page").hidden = !d;
190
+ if (!d) { $("info").textContent = ""; return; }
191
+ $("info").textContent = d.info; $("info").title = d.info;
192
+ $("raw").href = "/svg/" + encodeURIComponent(d.id);
193
+ const old = svgEl(); if (old) old.remove();
194
+ const parsed = new DOMParser().parseFromString(d.svg, "image/svg+xml").documentElement;
195
+ if (parsed.nodeName !== "svg") { $("status").textContent = "preview could not parse the SVG"; return; }
196
+ $("page").insertBefore(document.importNode(parsed, true), $("ov"));
197
+ $("page").style.backgroundColor = d.background || "#fff";
198
+ $("page").classList.toggle("bg", !!d.background);
199
+ size(); highlight(d.touched);
200
+ }
201
+ function size() {
202
+ const s = svgEl(); if (!s) return;
203
+ s.style.width = s.style.height = "";
204
+ const r = s.getBoundingClientRect();
205
+ if (fit && r.width && r.height) {
206
+ const W = $("wrap").clientWidth - 48, H = $("wrap").clientHeight - 48, k = Math.min(W / r.width, H / r.height);
207
+ s.style.width = r.width * k + "px"; s.style.height = r.height * k + "px";
208
+ }
209
+ $("zoom").textContent = fit ? "fit" : "100%";
210
+ }
211
+ function boxOf(el, cls) {
212
+ const pr = $("page").getBoundingClientRect(), r = el.getBoundingClientRect();
213
+ if (!r.width && !r.height) return null;
214
+ const b = document.createElement("div"); b.className = cls;
215
+ Object.assign(b.style, {left: r.left - pr.left - 3 + "px", top: r.top - pr.top - 3 + "px",
216
+ width: r.width + 6 + "px", height: r.height + 6 + "px"});
217
+ return b;
218
+ }
219
+ function highlight(ids) {
220
+ const ov = $("ov"); ov.querySelectorAll(".box").forEach(b => b.remove());
221
+ for (const id of ids || []) {
222
+ const el = svgEl() && svgEl().querySelector('[id="' + CSS.escape(id) + '"]');
223
+ if (!el || el.closest("defs")) continue;
224
+ const b = boxOf(el, "box"); if (b) ov.appendChild(b);
225
+ }
226
+ setTimeout(() => ov.querySelectorAll(".box").forEach(b => b.style.opacity = 0), 2500);
227
+ }
228
+ function pick(t) {
229
+ const s = svgEl();
230
+ while (t && t !== s && !(t.id && t.getAttributeNS(INK, "groupmode") !== "layer")) t = t.parentNode;
231
+ return t && t !== s && t.id ? t : null;
232
+ }
233
+ function where(el) {
234
+ const path = []; let p = el.parentNode;
235
+ while (p && p !== svgEl()) {
236
+ if (p.id) path.unshift(p.getAttributeNS(INK, "groupmode") === "layer" ? 'layer "' + p.getAttributeNS(INK, "label") + '" #' + p.id : "#" + p.id);
237
+ p = p.parentNode;
238
+ }
239
+ return "#" + el.id + " <" + el.nodeName + ">" + (path.length ? " in " + path.join(" › ") : "");
240
+ }
241
+ $("page").addEventListener("mousemove", e => {
242
+ $("ov").querySelectorAll(".hov").forEach(b => b.remove());
243
+ const el = pick(e.target); if (!el) return;
244
+ const b = boxOf(el, "hov"); if (b) $("ov").appendChild(b);
245
+ });
246
+ $("page").addEventListener("mouseleave", () => $("ov").querySelectorAll(".hov").forEach(b => b.remove()));
247
+ $("page").addEventListener("click", e => {
248
+ const el = pick(e.target); if (!el) return;
249
+ $("status").textContent = where(el);
250
+ navigator.clipboard && navigator.clipboard.writeText(el.id).catch(() => {});
251
+ });
252
+ $("zoom").onclick = () => { fit = !fit; size(); highlight([]); };
253
+ addEventListener("resize", () => { if (fit) size(); });
254
+ addEventListener("hashchange", () => { active = decodeURIComponent(location.hash.slice(1)); tabs(); show(); });
255
+
256
+ const es = new EventSource("/events");
257
+ es.onopen = () => { $("status").textContent = "live — click an element to copy its id"; };
258
+ es.onerror = () => { $("status").textContent = "disconnected, retrying… (is the MCP server still running?)"; };
259
+ es.onmessage = e => {
260
+ const m = JSON.parse(e.data);
261
+ if (m.type === "doc") {
262
+ if (docs[m.id] && docs[m.id].version > m.version) return;
263
+ docs[m.id] = m;
264
+ if (!active || !docs[active]) { active = m.id; history.replaceState(null, "", "#" + m.id); }
265
+ tabs();
266
+ if (m.id === active) { show(); if (m.summary) $("status").textContent = m.summary.split("\n")[0]; }
267
+ else { const t = [...document.querySelectorAll(".tab")].find(x => x.title === m.id); if (t) t.classList.add("flash"); }
268
+ } else if (m.type === "closed") {
269
+ delete docs[m.id];
270
+ if (active === m.id) active = Object.keys(docs)[0] || null;
271
+ tabs(); show();
272
+ }
273
+ };
274
+ </script></body></html>
275
+ """
pac_vector/render.py ADDED
@@ -0,0 +1,168 @@
1
+ """Rasterising with resvg, and visual bounding boxes measured from pixels.
2
+
3
+ There is no browser here, so geometry (text, strokes, filters, curves) is
4
+ measured the robust way: render the element alone onto a transparent canvas and
5
+ read the alpha channel's extent. Two passes — a coarse one over a wide region
6
+ around the page, then a fine one zoomed onto the hit — give sub-pixel-ish
7
+ precision for anything within two page-widths of the canvas.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import copy
13
+ import io
14
+
15
+ import resvg_py
16
+ from PIL import Image as PILImage
17
+
18
+ from ._xml import children, is_svg, iter_els, local, numbers, parse_length
19
+
20
+ MAX_SIDE = 2048
21
+
22
+ _UNIT_PX = {"": 1.0, "px": 1.0, "mm": 96 / 25.4, "cm": 96 / 2.54, "in": 96.0, "pt": 96 / 72, "pc": 16.0}
23
+
24
+ Box = tuple[float, float, float, float] # x, y, w, h
25
+
26
+
27
+ def viewbox(root) -> Box:
28
+ vb = numbers(root.get("viewBox") or "")
29
+ if len(vb) == 4 and vb[2] > 0 and vb[3] > 0:
30
+ return vb[0], vb[1], vb[2], vb[3]
31
+ w, h = size_px(root, use_viewbox=False)
32
+ return 0.0, 0.0, w, h
33
+
34
+
35
+ def size_px(root, use_viewbox: bool = True) -> tuple[float, float]:
36
+ out = []
37
+ for attr, default in (("width", 300.0), ("height", 150.0)):
38
+ ln = parse_length(root.get(attr))
39
+ if ln and ln[1] in _UNIT_PX:
40
+ out.append(ln[0] * _UNIT_PX[ln[1]])
41
+ elif use_viewbox:
42
+ vb = numbers(root.get("viewBox") or "")
43
+ out.append(vb[2] if attr == "width" and len(vb) == 4 else vb[3] if len(vb) == 4 else default)
44
+ else:
45
+ out.append(default)
46
+ return out[0], out[1]
47
+
48
+
49
+ def _has_text(root) -> bool:
50
+ return any(local(e) in ("text", "textPath", "tspan") for e in iter_els(root))
51
+
52
+
53
+ def _png(root, background: str | None, resources_dir: str | None) -> bytes:
54
+ from lxml import etree
55
+
56
+ svg = etree.tostring(root, encoding="unicode")
57
+ try:
58
+ return resvg_py.svg_to_bytes(
59
+ svg_string=svg,
60
+ background=background,
61
+ # Font DB loading is the slow part; only pay it when text exists.
62
+ skip_system_fonts=not _has_text(root),
63
+ resources_dir=resources_dir,
64
+ log_information=False, # True would print to stdout = the JSON-RPC pipe
65
+ )
66
+ except ValueError as e:
67
+ from ._xml import EditError
68
+
69
+ raise EditError(f"render failed: {e}") from None
70
+
71
+
72
+ def render_region(root, region: Box, pw: int, ph: int, background=None, resources_dir=None) -> bytes:
73
+ clone = copy.deepcopy(root)
74
+ clone.set("viewBox", " ".join(f"{v:.6g}" for v in region))
75
+ clone.set("width", str(pw))
76
+ clone.set("height", str(ph))
77
+ clone.set("preserveAspectRatio", "none")
78
+ return _png(clone, background, resources_dir)
79
+
80
+
81
+ def render_page(root, scale: float = 1.0, background=None, resources_dir=None) -> tuple[bytes, int, int, float]:
82
+ """Whole page. Returns (png, w, h, effective_scale); scale is capped so the
83
+ longest side stays ≤ MAX_SIDE."""
84
+ w, h = size_px(root)
85
+ scale = min(scale, MAX_SIDE / max(w, h, 1))
86
+ pw, ph = max(1, round(w * scale)), max(1, round(h * scale))
87
+ return render_region(root, viewbox(root), pw, ph, background, resources_dir), pw, ph, scale
88
+
89
+
90
+ def alpha_bbox(png: bytes) -> tuple[int, int, int, int] | None:
91
+ im = PILImage.open(io.BytesIO(png))
92
+ if im.mode != "RGBA":
93
+ im = im.convert("RGBA")
94
+ return im.getchannel("A").getbbox()
95
+
96
+
97
+ def _bbox_in(root, region: Box, longest: int) -> Box | None:
98
+ rx, ry, rw, rh = region
99
+ if rw <= 0 or rh <= 0:
100
+ return None
101
+ s = longest / max(rw, rh)
102
+ pw, ph = max(1, round(rw * s)), max(1, round(rh * s))
103
+ bb = alpha_bbox(render_region(root, region, pw, ph))
104
+ if not bb:
105
+ return None
106
+ x0, y0, x1, y1 = bb
107
+ return rx + x0 * rw / pw, ry + y0 * rh / ph, (x1 - x0) * rw / pw, (y1 - y0) * rh / ph
108
+
109
+
110
+ def measure(root) -> Box | None:
111
+ """Visual bbox (stroke, filters included) of everything drawable in `root`,
112
+ in root user units. None if nothing visible within 2 page-widths."""
113
+ vx, vy, vw, vh = viewbox(root)
114
+ coarse = _bbox_in(root, (vx - 2 * vw, vy - 2 * vh, 5 * vw, 5 * vh), 800)
115
+ if not coarse:
116
+ return None
117
+ x, y, w, h = coarse
118
+ m = max(5 * max(vw, vh) / 800 * 2, max(w, h) * 0.02)
119
+ fine = _bbox_in(root, (x - m, y - m, w + 2 * m, h + 2 * m), 1024)
120
+ return fine or coarse
121
+
122
+
123
+ def isolate(root, ids: set[str]):
124
+ """Clone of `root` keeping only the elements with these ids (plus their
125
+ ancestors, descendants and all non-drawable parts like <defs>)."""
126
+ clone = copy.deepcopy(root)
127
+ targets = {e for e in iter_els(clone) if e.get("id") in ids}
128
+ ancestors = {a for t in targets for a in t.iterancestors()}
129
+
130
+ def prune(node):
131
+ for c in children(node):
132
+ if c in targets:
133
+ continue
134
+ if c in ancestors:
135
+ prune(c)
136
+ elif not is_svg(c) or local(c) in ("defs", "style", "metadata", "title", "desc"):
137
+ continue
138
+ else:
139
+ node.remove(c)
140
+
141
+ prune(clone)
142
+ return clone
143
+
144
+
145
+ def union(boxes: list[Box]) -> Box | None:
146
+ boxes = [b for b in boxes if b]
147
+ if not boxes:
148
+ return None
149
+ x0 = min(b[0] for b in boxes)
150
+ y0 = min(b[1] for b in boxes)
151
+ x1 = max(b[0] + b[2] for b in boxes)
152
+ y1 = max(b[1] + b[3] for b in boxes)
153
+ return x0, y0, x1 - x0, y1 - y0
154
+
155
+
156
+ def warm() -> None:
157
+ """Load the renderer, the font DB and PIL's PNG codec BEFORE the MCP loop
158
+ starts. On this Windows setup a native module first loaded from inside the
159
+ FastMCP stdio loop can wedge the process (see pac-pincers' _excel.py)."""
160
+ from lxml import etree
161
+
162
+ from ._xml import parse_document
163
+
164
+ root = parse_document(
165
+ '<svg width="8" height="8"><rect width="4" height="4"/><text y="8">a</text></svg>'
166
+ )
167
+ alpha_bbox(render_page(root)[0])
168
+ etree.tostring(root)