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/__init__.py +1 -0
- pac_vector/__main__.py +20 -0
- pac_vector/_xml.py +348 -0
- pac_vector/document.py +1375 -0
- pac_vector/preview.py +275 -0
- pac_vector/render.py +168 -0
- pac_vector/server.py +320 -0
- pac_vector-0.1.0.dist-info/METADATA +88 -0
- pac_vector-0.1.0.dist-info/RECORD +11 -0
- pac_vector-0.1.0.dist-info/WHEEL +4 -0
- pac_vector-0.1.0.dist-info/entry_points.txt +2 -0
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 => ({"&":"&","<":"<",">":">",'"':"""}[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)
|