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/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""pac-vector: Local stdio MCP server for SVG editing via in-memory virtual documents."""
|
pac_vector/__main__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main() -> None:
|
|
7
|
+
# stdout is the JSON-RPC pipe: logs go to stderr only.
|
|
8
|
+
logging.basicConfig(stream=sys.stderr, level=os.environ.get("PAC_VECTOR_LOG", "WARNING").upper())
|
|
9
|
+
# Load every native module (lxml, resvg, PIL codecs, font DB) BEFORE the
|
|
10
|
+
# stdio loop starts — on Windows a first native load from inside the
|
|
11
|
+
# FastMCP loop can hang the process (see pac-pincers' _excel.py).
|
|
12
|
+
from .render import warm
|
|
13
|
+
from .server import mcp
|
|
14
|
+
|
|
15
|
+
warm()
|
|
16
|
+
mcp.run()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
if __name__ == "__main__":
|
|
20
|
+
main()
|
pac_vector/_xml.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""XML/SVG helpers: namespaces, safe parsing, sanitising, styles, number
|
|
2
|
+
formatting and 2D affine transforms. No document state lives here."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
from typing import Iterable, Iterator
|
|
9
|
+
|
|
10
|
+
from lxml import etree
|
|
11
|
+
|
|
12
|
+
SVG_NS = "http://www.w3.org/2000/svg"
|
|
13
|
+
XLINK_NS = "http://www.w3.org/1999/xlink"
|
|
14
|
+
INK_NS = "http://www.inkscape.org/namespaces/inkscape"
|
|
15
|
+
SODI_NS = "http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
|
16
|
+
XML_NS = "http://www.w3.org/XML/1998/namespace"
|
|
17
|
+
|
|
18
|
+
NSMAP = {None: SVG_NS, "xlink": XLINK_NS, "inkscape": INK_NS, "sodipodi": SODI_NS}
|
|
19
|
+
PREFIXES = {"xlink": XLINK_NS, "inkscape": INK_NS, "sodipodi": SODI_NS, "xml": XML_NS}
|
|
20
|
+
|
|
21
|
+
INK_LABEL = f"{{{INK_NS}}}label"
|
|
22
|
+
INK_GROUPMODE = f"{{{INK_NS}}}groupmode"
|
|
23
|
+
SODI_INSENSITIVE = f"{{{SODI_NS}}}insensitive"
|
|
24
|
+
XLINK_HREF = f"{{{XLINK_NS}}}href"
|
|
25
|
+
|
|
26
|
+
ELIDED_MARK = "⟪elided"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EditError(Exception):
|
|
30
|
+
"""A user-facing error: the message tells the agent what to fix."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def q(tag: str) -> str:
|
|
34
|
+
return f"{{{SVG_NS}}}{tag}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_el(node) -> bool:
|
|
38
|
+
return isinstance(node.tag, str)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def local(el) -> str:
|
|
42
|
+
return etree.QName(el).localname if is_el(el) else ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def is_svg(el) -> bool:
|
|
46
|
+
return is_el(el) and etree.QName(el).namespace == SVG_NS
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def children(el) -> list:
|
|
50
|
+
return [c for c in el if is_el(c)]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def iter_els(el) -> Iterator:
|
|
54
|
+
for e in el.iter():
|
|
55
|
+
if is_el(e):
|
|
56
|
+
yield e
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ── parsing ──────────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
# resolve_entities=False + no_network: no XXE, no billion laughs.
|
|
62
|
+
# huge_tree: embedded base64 images easily exceed libxml2's 10 MB text limit.
|
|
63
|
+
PARSER = etree.XMLParser(resolve_entities=False, no_network=True, huge_tree=True)
|
|
64
|
+
|
|
65
|
+
_PROLOG = re.compile(r"^\s*(<\?xml[^>]*\?>)?\s*(<!DOCTYPE[^>\[]*(\[[^\]]*\])?\s*>)?", re.S)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse_fragment(markup: str) -> list:
|
|
69
|
+
"""Parse one or more SVG elements. Unprefixed elements land in the SVG
|
|
70
|
+
namespace; the xlink/inkscape/sodipodi prefixes are pre-declared."""
|
|
71
|
+
if not markup or not markup.strip():
|
|
72
|
+
raise EditError("markup is empty")
|
|
73
|
+
if ELIDED_MARK in markup:
|
|
74
|
+
raise EditError(
|
|
75
|
+
"markup contains data elided by inspect (⟪elided …⟫). Reference the existing "
|
|
76
|
+
"element by id (arrange copy=true duplicates it) or pass the full data."
|
|
77
|
+
)
|
|
78
|
+
body = _PROLOG.sub("", markup, count=1)
|
|
79
|
+
decl = " ".join(f'xmlns:{p}="{ns}"' for p, ns in PREFIXES.items() if p != "xml")
|
|
80
|
+
wrapped = f'<svg xmlns="{SVG_NS}" {decl}>{body}</svg>'
|
|
81
|
+
try:
|
|
82
|
+
root = etree.fromstring(wrapped.encode("utf-8"), PARSER)
|
|
83
|
+
except etree.XMLSyntaxError as e:
|
|
84
|
+
raise EditError(f"invalid SVG markup: {e.msg} (line {e.lineno})") from None
|
|
85
|
+
els = children(root)
|
|
86
|
+
if not els:
|
|
87
|
+
raise EditError("markup has no elements")
|
|
88
|
+
for el in els:
|
|
89
|
+
root.remove(el)
|
|
90
|
+
el.tail = None
|
|
91
|
+
return els
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def parse_document(text: str):
|
|
95
|
+
"""Parse a whole <svg> document into a root normalised to NSMAP."""
|
|
96
|
+
els = parse_fragment(text)
|
|
97
|
+
if len(els) != 1 or local(els[0]) != "svg":
|
|
98
|
+
raise EditError("expected a single <svg> root element")
|
|
99
|
+
return normalise_root(els[0])
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def normalise_root(src):
|
|
103
|
+
root = etree.Element(q("svg"), nsmap=NSMAP)
|
|
104
|
+
for k, v in src.attrib.items():
|
|
105
|
+
root.set(k, v)
|
|
106
|
+
root.text = src.text
|
|
107
|
+
for c in list(src):
|
|
108
|
+
root.append(c)
|
|
109
|
+
return root
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def new_root():
|
|
113
|
+
return etree.Element(q("svg"), nsmap=NSMAP)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def from_bytes(data: bytes):
|
|
117
|
+
return etree.fromstring(data, PARSER)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def sanitize(els: Iterable) -> list[str]:
|
|
121
|
+
"""Remove scripts, event handlers and javascript: links. Returns notes."""
|
|
122
|
+
notes = []
|
|
123
|
+
for top in els:
|
|
124
|
+
for e in list(iter_els(top)):
|
|
125
|
+
if local(e) == "script":
|
|
126
|
+
parent = e.getparent()
|
|
127
|
+
if parent is not None:
|
|
128
|
+
parent.remove(e)
|
|
129
|
+
notes.append("removed <script>")
|
|
130
|
+
continue
|
|
131
|
+
for k in list(e.attrib):
|
|
132
|
+
name = etree.QName(k).localname.lower()
|
|
133
|
+
v = e.attrib[k]
|
|
134
|
+
if name.startswith("on"):
|
|
135
|
+
del e.attrib[k]
|
|
136
|
+
notes.append(f"removed {name} handler")
|
|
137
|
+
elif name == "href" and v.strip().lower().startswith("javascript:"):
|
|
138
|
+
del e.attrib[k]
|
|
139
|
+
notes.append("removed javascript: href")
|
|
140
|
+
return sorted(set(notes))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def attr_name(name: str) -> str:
|
|
144
|
+
"""'xlink:href' → '{ns}href'. Plain names pass through."""
|
|
145
|
+
if ":" in name and not name.startswith("{"):
|
|
146
|
+
prefix, loc = name.split(":", 1)
|
|
147
|
+
if prefix not in PREFIXES:
|
|
148
|
+
raise EditError(f"unknown attribute prefix '{prefix}:' (known: {', '.join(PREFIXES)})")
|
|
149
|
+
return f"{{{PREFIXES[prefix]}}}{loc}"
|
|
150
|
+
return name
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def short_attr(k: str) -> str:
|
|
154
|
+
qn = etree.QName(k)
|
|
155
|
+
if qn.namespace is None:
|
|
156
|
+
return qn.localname
|
|
157
|
+
for p, ns in PREFIXES.items():
|
|
158
|
+
if ns == qn.namespace:
|
|
159
|
+
return f"{p}:{qn.localname}"
|
|
160
|
+
return qn.localname
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def href(el) -> str | None:
|
|
164
|
+
return el.get("href") or el.get(XLINK_HREF)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ── numbers & styles ─────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def fmt(v) -> str:
|
|
171
|
+
if isinstance(v, str):
|
|
172
|
+
return v
|
|
173
|
+
if isinstance(v, bool):
|
|
174
|
+
return "true" if v else "false"
|
|
175
|
+
x = float(v)
|
|
176
|
+
if not math.isfinite(x):
|
|
177
|
+
raise EditError(f"non-finite number {v!r}")
|
|
178
|
+
s = f"{x:.4f}".rstrip("0").rstrip(".")
|
|
179
|
+
return "0" if s in ("-0", "") else s
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def parse_style(s: str | None) -> dict[str, str]:
|
|
183
|
+
out: dict[str, str] = {}
|
|
184
|
+
for part in (s or "").split(";"):
|
|
185
|
+
if ":" in part:
|
|
186
|
+
k, v = part.split(":", 1)
|
|
187
|
+
if k.strip():
|
|
188
|
+
out[k.strip()] = v.strip()
|
|
189
|
+
return out
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def style_str(d: dict[str, str]) -> str:
|
|
193
|
+
return ";".join(f"{k}:{v}" for k, v in d.items())
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def get_prop(el, name: str) -> str | None:
|
|
197
|
+
"""Effective own value of a presentation property (style beats attribute)."""
|
|
198
|
+
st = parse_style(el.get("style"))
|
|
199
|
+
if name in st:
|
|
200
|
+
return st[name]
|
|
201
|
+
return el.get(name)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
_NUM = re.compile(r"^\s*([-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?)\s*([a-z%]*)\s*$")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def parse_length(s: str | None) -> tuple[float, str] | None:
|
|
208
|
+
if s is None:
|
|
209
|
+
return None
|
|
210
|
+
m = _NUM.match(s)
|
|
211
|
+
if not m:
|
|
212
|
+
return None
|
|
213
|
+
return float(m.group(1)), m.group(2)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def numbers(s: str) -> list[float]:
|
|
217
|
+
return [float(x) for x in re.findall(r"[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?", s or "")]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ── references ───────────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
URL_REF = re.compile(r"url\(\s*['\"]?#([^)'\"\s]+)['\"]?\s*\)")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def refs_of(el) -> set[str]:
|
|
226
|
+
"""Ids this element references (url(#x) anywhere, href="#x")."""
|
|
227
|
+
out = set()
|
|
228
|
+
for k, v in el.attrib.items():
|
|
229
|
+
out.update(URL_REF.findall(v))
|
|
230
|
+
if etree.QName(k).localname == "href" and v.startswith("#"):
|
|
231
|
+
out.add(v[1:])
|
|
232
|
+
return out
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def rewrite_refs(tops: Iterable, mapping: dict[str, str]) -> None:
|
|
236
|
+
if not mapping:
|
|
237
|
+
return
|
|
238
|
+
sub = lambda m: f"url(#{mapping.get(m.group(1), m.group(1))})" # noqa: E731
|
|
239
|
+
for top in tops:
|
|
240
|
+
for e in iter_els(top):
|
|
241
|
+
for k, v in list(e.attrib.items()):
|
|
242
|
+
nv = URL_REF.sub(sub, v)
|
|
243
|
+
if etree.QName(k).localname == "href" and nv.startswith("#") and nv[1:] in mapping:
|
|
244
|
+
nv = "#" + mapping[nv[1:]]
|
|
245
|
+
if nv != v:
|
|
246
|
+
e.set(k, nv)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ── affine transforms (a, b, c, d, e, f) as in SVG matrix() ──────────────────
|
|
250
|
+
|
|
251
|
+
Matrix = tuple[float, float, float, float, float, float]
|
|
252
|
+
IDENT: Matrix = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def mul(m1: Matrix, m2: Matrix) -> Matrix:
|
|
256
|
+
"""m1 · m2 — apply m2 first, then m1."""
|
|
257
|
+
a1, b1, c1, d1, e1, f1 = m1
|
|
258
|
+
a2, b2, c2, d2, e2, f2 = m2
|
|
259
|
+
return (
|
|
260
|
+
a1 * a2 + c1 * b2,
|
|
261
|
+
b1 * a2 + d1 * b2,
|
|
262
|
+
a1 * c2 + c1 * d2,
|
|
263
|
+
b1 * c2 + d1 * d2,
|
|
264
|
+
a1 * e2 + c1 * f2 + e1,
|
|
265
|
+
b1 * e2 + d1 * f2 + f1,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def inv(m: Matrix) -> Matrix:
|
|
270
|
+
a, b, c, d, e, f = m
|
|
271
|
+
det = a * d - b * c
|
|
272
|
+
if abs(det) < 1e-12:
|
|
273
|
+
raise EditError("transform is not invertible (zero scale?)")
|
|
274
|
+
return (d / det, -b / det, -c / det, a / det, (c * f - d * e) / det, (b * e - a * f) / det)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def apply(m: Matrix, x: float, y: float) -> tuple[float, float]:
|
|
278
|
+
a, b, c, d, e, f = m
|
|
279
|
+
return a * x + c * y + e, b * x + d * y + f
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def is_ident(m: Matrix, eps: float = 1e-9) -> bool:
|
|
283
|
+
return all(abs(x - y) < eps for x, y in zip(m, IDENT))
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def same(m1: Matrix, m2: Matrix, eps: float = 1e-9) -> bool:
|
|
287
|
+
return all(abs(x - y) < eps for x, y in zip(m1, m2))
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
_TF = re.compile(r"(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def parse_transform(s: str | None) -> Matrix:
|
|
294
|
+
m = IDENT
|
|
295
|
+
for name, args in _TF.findall(s or ""):
|
|
296
|
+
n = numbers(args)
|
|
297
|
+
if name == "matrix" and len(n) == 6:
|
|
298
|
+
t = tuple(n)
|
|
299
|
+
elif name == "translate":
|
|
300
|
+
t = (1, 0, 0, 1, n[0] if n else 0, n[1] if len(n) > 1 else 0)
|
|
301
|
+
elif name == "scale":
|
|
302
|
+
sx = n[0] if n else 1
|
|
303
|
+
t = (sx, 0, 0, n[1] if len(n) > 1 else sx, 0, 0)
|
|
304
|
+
elif name == "rotate":
|
|
305
|
+
a = math.radians(n[0] if n else 0)
|
|
306
|
+
r = (math.cos(a), math.sin(a), -math.sin(a), math.cos(a), 0, 0)
|
|
307
|
+
if len(n) >= 3:
|
|
308
|
+
cx, cy = n[1], n[2]
|
|
309
|
+
t = mul(mul((1, 0, 0, 1, cx, cy), r), (1, 0, 0, 1, -cx, -cy))
|
|
310
|
+
else:
|
|
311
|
+
t = r
|
|
312
|
+
elif name == "skewX":
|
|
313
|
+
t = (1, 0, math.tan(math.radians(n[0] if n else 0)), 1, 0, 0)
|
|
314
|
+
elif name == "skewY":
|
|
315
|
+
t = (1, math.tan(math.radians(n[0] if n else 0)), 0, 1, 0, 0)
|
|
316
|
+
else:
|
|
317
|
+
continue
|
|
318
|
+
m = mul(m, t) # type: ignore[arg-type]
|
|
319
|
+
return m
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def matrix_str(m: Matrix) -> str:
|
|
323
|
+
return "matrix(" + ",".join(fmt(x) for x in m) + ")"
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def prepend_transform(el, tf: str) -> None:
|
|
327
|
+
"""Apply `tf` on top of the element's current transform (in parent space)."""
|
|
328
|
+
old = (el.get("transform") or "").strip()
|
|
329
|
+
new = f"{tf} {old}".strip() if tf else old
|
|
330
|
+
if len(_TF.findall(new)) > 4: # keep chains short and readable
|
|
331
|
+
new = matrix_str(parse_transform(new))
|
|
332
|
+
if new and not is_ident(parse_transform(new)):
|
|
333
|
+
el.set("transform", new)
|
|
334
|
+
elif "transform" in el.attrib:
|
|
335
|
+
del el.attrib["transform"]
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def parent_ctm(el) -> Matrix:
|
|
339
|
+
"""Product of ancestors' transforms (root first), excluding el's own."""
|
|
340
|
+
chain = [a for a in el.iterancestors()]
|
|
341
|
+
m = IDENT
|
|
342
|
+
for a in reversed(chain):
|
|
343
|
+
m = mul(m, parse_transform(a.get("transform")))
|
|
344
|
+
return m
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def el_ctm(el) -> Matrix:
|
|
348
|
+
return mul(parent_ctm(el), parse_transform(el.get("transform")))
|