pac-vector 0.1.0__tar.gz

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,28 @@
1
+ # OS files
2
+ .DS_Store
3
+ Thumbs.db
4
+
5
+ # Visual Studio Code
6
+ .vscode/
7
+
8
+ # Logs
9
+ logs/
10
+ *.log
11
+
12
+ # Claude Code agent worktrees — checkouts of this same repo nested inside it
13
+ .claude/
14
+
15
+ # Virtual environment
16
+ .venv/
17
+
18
+ # Build output
19
+ dist/
20
+ build/
21
+ *.egg-info/
22
+ /__pycache__/
23
+ *.pyc
24
+ .*_cache
25
+ __pycache__
26
+
27
+ # Local exports while testing
28
+ out/
@@ -0,0 +1,88 @@
1
+ Metadata-Version: 2.5
2
+ Name: pac-vector
3
+ Version: 0.1.0
4
+ Summary: Local stdio MCP server for SVG editing via in-memory virtual documents, with a live browser preview
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: lxml>=5.0
7
+ Requires-Dist: mcp[cli]<2.0.0,>=1.10.0
8
+ Requires-Dist: pillow>=10.0
9
+ Requires-Dist: resvg-py>=0.5.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=8.0; extra == 'dev'
12
+ Description-Content-Type: text/markdown
13
+
14
+ # pac-vector
15
+
16
+ Local stdio MCP server: an SVG editor over in-memory **virtual documents** (one per
17
+ "window", like Inkscape), with a **live browser preview** of every edit.
18
+
19
+ - Content goes in as SVG markup; structure (layers, groups, masks, z-order) has typed tools.
20
+ - Layers are real Inkscape layers, so exported files open in Inkscape with layers intact.
21
+ - Every edit is atomic (a failed call changes nothing) and one undo step.
22
+ - `render` returns a PNG the agent can look at; rendering is resvg (no browser, no Cairo).
23
+
24
+ ## Install
25
+
26
+ Same shape as pac-pincers: a pip package, run as a module by whatever Python it
27
+ was installed into.
28
+
29
+ ```powershell
30
+ python -m pip install --upgrade pac-vector
31
+ claude mcp add pac-vector --scope user -- python -m pac_vector
32
+ ```
33
+
34
+ As a plugin `.mcp.json` entry (mirrors the datatopia plugin's pac-pincers entry):
35
+
36
+ ```json
37
+ "pac-vector": {
38
+ "type": "stdio",
39
+ "command": "${PAC_VECTOR_PYTHON:-python}",
40
+ "args": ["-m", "pac_vector"],
41
+ "env": {}
42
+ }
43
+ ```
44
+
45
+ Development:
46
+
47
+ ```powershell
48
+ python -m venv .venv
49
+ .\.venv\Scripts\python.exe -m pip install -e ".[dev]"
50
+ .\.venv\Scripts\python.exe run_tests.py -q
51
+ .\.venv\Scripts\python.exe -m build # dist/pac_vector-<ver>.tar.gz + .whl
52
+ ```
53
+
54
+ ## Tools (17)
55
+
56
+ | Group | Tools |
57
+ |---|---|
58
+ | Documents | `doc_create` (blank or import), `doc_list`, `doc_set` (size, units, viewBox, background, fit), `doc_export` (svg/png, page/drawing/ids, plain), `doc_close` |
59
+ | Reading | `inspect` (outline or xml), `render` (PNG, optional crop to ids + bbox) |
60
+ | Editing | `add` (markup → layer/group, defs auto-routed), `update` (attrs/style/text/transform/label/hidden/locked on many ids), `replace` (one element or the whole drawing), `remove`, `arrange` (z-order, move between layers, duplicate, offset) |
61
+ | Structure | `group` (also new layers), `ungroup`, `mask` (mask or clip from id or markup), `unmask` |
62
+ | History | `undo` |
63
+
64
+ ## Preview
65
+
66
+ `doc_create` / `doc_list` / `inspect` print `preview: http://127.0.0.1:<port>/#<doc>`.
67
+ The page has a tab per document, outlines the elements each edit touched, and
68
+ shows (and copies) the id of anything you click. The server lives inside the MCP
69
+ process: it starts on the first `doc_create` and dies with the session.
70
+
71
+ ## Environment
72
+
73
+ | Variable | Default | Meaning |
74
+ |---|---|---|
75
+ | `PAC_VECTOR_PORT` | `0` (random) | Pin the preview port (falls back to random if busy) |
76
+ | `PAC_VECTOR_OPEN_BROWSER` | off | `1` opens the preview in the default browser on first start |
77
+ | `PAC_VECTOR_ROOT` | server cwd | Base directory for relative export/import paths |
78
+ | `PAC_VECTOR_LOG` | `WARNING` | Log level (stderr only) |
79
+
80
+ ## Notes
81
+
82
+ - stdout is the JSON-RPC pipe: nothing may print to it. `tests/test_stdout_safety.py`
83
+ scans the package for `print()` / `sys.stdout`, and `tests/test_stdio.py` runs a real session.
84
+ - Native modules are loaded before the stdio loop starts (`render.warm`): on Windows a
85
+ first native load from inside the FastMCP loop can hang (see pac-pincers' `_excel.py`).
86
+ - Bounding boxes are measured from pixels (two-pass render), so they include strokes and
87
+ filters and work for text and curves; content further than two page-widths off-canvas isn't found.
88
+ - Version lives only in `pyproject.toml`; a release is a commit named after the version.
@@ -0,0 +1,75 @@
1
+ # pac-vector
2
+
3
+ Local stdio MCP server: an SVG editor over in-memory **virtual documents** (one per
4
+ "window", like Inkscape), with a **live browser preview** of every edit.
5
+
6
+ - Content goes in as SVG markup; structure (layers, groups, masks, z-order) has typed tools.
7
+ - Layers are real Inkscape layers, so exported files open in Inkscape with layers intact.
8
+ - Every edit is atomic (a failed call changes nothing) and one undo step.
9
+ - `render` returns a PNG the agent can look at; rendering is resvg (no browser, no Cairo).
10
+
11
+ ## Install
12
+
13
+ Same shape as pac-pincers: a pip package, run as a module by whatever Python it
14
+ was installed into.
15
+
16
+ ```powershell
17
+ python -m pip install --upgrade pac-vector
18
+ claude mcp add pac-vector --scope user -- python -m pac_vector
19
+ ```
20
+
21
+ As a plugin `.mcp.json` entry (mirrors the datatopia plugin's pac-pincers entry):
22
+
23
+ ```json
24
+ "pac-vector": {
25
+ "type": "stdio",
26
+ "command": "${PAC_VECTOR_PYTHON:-python}",
27
+ "args": ["-m", "pac_vector"],
28
+ "env": {}
29
+ }
30
+ ```
31
+
32
+ Development:
33
+
34
+ ```powershell
35
+ python -m venv .venv
36
+ .\.venv\Scripts\python.exe -m pip install -e ".[dev]"
37
+ .\.venv\Scripts\python.exe run_tests.py -q
38
+ .\.venv\Scripts\python.exe -m build # dist/pac_vector-<ver>.tar.gz + .whl
39
+ ```
40
+
41
+ ## Tools (17)
42
+
43
+ | Group | Tools |
44
+ |---|---|
45
+ | Documents | `doc_create` (blank or import), `doc_list`, `doc_set` (size, units, viewBox, background, fit), `doc_export` (svg/png, page/drawing/ids, plain), `doc_close` |
46
+ | Reading | `inspect` (outline or xml), `render` (PNG, optional crop to ids + bbox) |
47
+ | Editing | `add` (markup → layer/group, defs auto-routed), `update` (attrs/style/text/transform/label/hidden/locked on many ids), `replace` (one element or the whole drawing), `remove`, `arrange` (z-order, move between layers, duplicate, offset) |
48
+ | Structure | `group` (also new layers), `ungroup`, `mask` (mask or clip from id or markup), `unmask` |
49
+ | History | `undo` |
50
+
51
+ ## Preview
52
+
53
+ `doc_create` / `doc_list` / `inspect` print `preview: http://127.0.0.1:<port>/#<doc>`.
54
+ The page has a tab per document, outlines the elements each edit touched, and
55
+ shows (and copies) the id of anything you click. The server lives inside the MCP
56
+ process: it starts on the first `doc_create` and dies with the session.
57
+
58
+ ## Environment
59
+
60
+ | Variable | Default | Meaning |
61
+ |---|---|---|
62
+ | `PAC_VECTOR_PORT` | `0` (random) | Pin the preview port (falls back to random if busy) |
63
+ | `PAC_VECTOR_OPEN_BROWSER` | off | `1` opens the preview in the default browser on first start |
64
+ | `PAC_VECTOR_ROOT` | server cwd | Base directory for relative export/import paths |
65
+ | `PAC_VECTOR_LOG` | `WARNING` | Log level (stderr only) |
66
+
67
+ ## Notes
68
+
69
+ - stdout is the JSON-RPC pipe: nothing may print to it. `tests/test_stdout_safety.py`
70
+ scans the package for `print()` / `sys.stdout`, and `tests/test_stdio.py` runs a real session.
71
+ - Native modules are loaded before the stdio loop starts (`render.warm`): on Windows a
72
+ first native load from inside the FastMCP loop can hang (see pac-pincers' `_excel.py`).
73
+ - Bounding boxes are measured from pixels (two-pass render), so they include strokes and
74
+ filters and work for text and curves; content further than two page-widths off-canvas isn't found.
75
+ - Version lives only in `pyproject.toml`; a release is a commit named after the version.
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pac-vector"
7
+ version = "0.1.0"
8
+ description = "Local stdio MCP server for SVG editing via in-memory virtual documents, with a live browser preview"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ # mcp 2.0 removed the vendored FastMCP (mcp.server.fastmcp) that this server is
13
+ # built on — pin to the 1.x line, same as pac-pincers.
14
+ "mcp[cli]>=1.10.0,<2.0.0",
15
+ # Namespace-correct XML (Inkscape layers live in the inkscape:/sodipodi: namespaces).
16
+ "lxml>=5.0",
17
+ # Rust SVG renderer for render / PNG export / bounding boxes. Ships wheels for
18
+ # Windows, so no Cairo DLLs and no browser.
19
+ "resvg-py>=0.5.0",
20
+ # PNG decode for pixel bounding boxes and crops.
21
+ "pillow>=10.0",
22
+ ]
23
+
24
+ [project.scripts]
25
+ pac-vector = "pac_vector.__main__:main"
26
+
27
+ [project.optional-dependencies]
28
+ dev = [
29
+ "pytest>=8.0",
30
+ ]
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/pac_vector"]
34
+
35
+ [tool.hatch.build.targets.sdist]
36
+ # Claude Code puts agent worktrees under .claude/worktrees/ — full checkouts of
37
+ # this same repo nested inside it — and hatchling would sweep them into the
38
+ # sdist (pac-pincers 1.3.7 shipped three copies of itself that way). Keep the
39
+ # exclusion explicit rather than relying on .gitignore.
40
+ exclude = [
41
+ "/.claude",
42
+ "/.venv",
43
+ "/out",
44
+ ]
45
+
46
+ [tool.pytest.ini_options]
47
+ testpaths = ["tests"]
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env python
2
+ """Stable test-runner entry point for pac-vector.
3
+
4
+ Always invoke the suite the same way — `python run_tests.py` — so the command
5
+ shape never changes (easy to allowlist). Any extra arguments are forwarded
6
+ straight to pytest, e.g.:
7
+
8
+ python run_tests.py # full suite
9
+ python run_tests.py -q # quiet
10
+ python run_tests.py tests/test_stdio.py # a single file
11
+ python run_tests.py -k mask # filter by keyword
12
+ """
13
+ import subprocess
14
+ import sys
15
+
16
+ if __name__ == "__main__":
17
+ sys.exit(subprocess.call([sys.executable, "-m", "pytest", *sys.argv[1:]]))
@@ -0,0 +1 @@
1
+ """pac-vector: Local stdio MCP server for SVG editing via in-memory virtual documents."""
@@ -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()
@@ -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")))