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.
- pac_vector-0.1.0/.gitignore +28 -0
- pac_vector-0.1.0/PKG-INFO +88 -0
- pac_vector-0.1.0/README.md +75 -0
- pac_vector-0.1.0/pyproject.toml +47 -0
- pac_vector-0.1.0/run_tests.py +17 -0
- pac_vector-0.1.0/src/pac_vector/__init__.py +1 -0
- pac_vector-0.1.0/src/pac_vector/__main__.py +20 -0
- pac_vector-0.1.0/src/pac_vector/_xml.py +348 -0
- pac_vector-0.1.0/src/pac_vector/document.py +1375 -0
- pac_vector-0.1.0/src/pac_vector/preview.py +275 -0
- pac_vector-0.1.0/src/pac_vector/render.py +168 -0
- pac_vector-0.1.0/src/pac_vector/server.py +320 -0
- pac_vector-0.1.0/tests/__init__.py +0 -0
- pac_vector-0.1.0/tests/test_document.py +261 -0
- pac_vector-0.1.0/tests/test_stdio.py +79 -0
- pac_vector-0.1.0/tests/test_stdout_safety.py +30 -0
|
@@ -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")))
|