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/document.py
ADDED
|
@@ -0,0 +1,1375 @@
|
|
|
1
|
+
"""Virtual documents (one per "window") and every edit operation.
|
|
2
|
+
|
|
3
|
+
Each mutating method is wrapped by @mutation: it snapshots the document first,
|
|
4
|
+
rolls back if anything raises (so a failed call never leaves a half-edit), and
|
|
5
|
+
on success pushes one undo step and notifies the preview. Mutating methods
|
|
6
|
+
return (summary, touched_ids); the decorator returns just the summary.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import copy
|
|
12
|
+
import difflib
|
|
13
|
+
import functools
|
|
14
|
+
import gzip
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import threading
|
|
18
|
+
import uuid
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from lxml import etree
|
|
22
|
+
|
|
23
|
+
from . import render as R
|
|
24
|
+
from ._xml import (
|
|
25
|
+
ELIDED_MARK,
|
|
26
|
+
INK_GROUPMODE,
|
|
27
|
+
INK_LABEL,
|
|
28
|
+
INK_NS,
|
|
29
|
+
SODI_INSENSITIVE,
|
|
30
|
+
SODI_NS,
|
|
31
|
+
URL_REF,
|
|
32
|
+
EditError,
|
|
33
|
+
attr_name,
|
|
34
|
+
children,
|
|
35
|
+
el_ctm,
|
|
36
|
+
fmt,
|
|
37
|
+
from_bytes,
|
|
38
|
+
get_prop,
|
|
39
|
+
href,
|
|
40
|
+
inv,
|
|
41
|
+
is_el,
|
|
42
|
+
is_ident,
|
|
43
|
+
is_svg,
|
|
44
|
+
iter_els,
|
|
45
|
+
local,
|
|
46
|
+
mul,
|
|
47
|
+
new_root,
|
|
48
|
+
parent_ctm,
|
|
49
|
+
parse_document,
|
|
50
|
+
parse_fragment,
|
|
51
|
+
parse_length,
|
|
52
|
+
parse_style,
|
|
53
|
+
parse_transform,
|
|
54
|
+
prepend_transform,
|
|
55
|
+
q,
|
|
56
|
+
refs_of,
|
|
57
|
+
rewrite_refs,
|
|
58
|
+
same,
|
|
59
|
+
sanitize,
|
|
60
|
+
short_attr,
|
|
61
|
+
style_str,
|
|
62
|
+
apply,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
DEFS_TAGS = {
|
|
66
|
+
"linearGradient", "radialGradient", "pattern", "filter", "marker",
|
|
67
|
+
"symbol", "clipPath", "mask", "style",
|
|
68
|
+
}
|
|
69
|
+
NON_DRAWABLE = {"defs", "namedview", "metadata", "title", "desc", "style", "script"}
|
|
70
|
+
AUTO_ID_SKIP = {"stop", "tspan", "title", "desc", "metadata", "style", "defs"}
|
|
71
|
+
CONTAINERS = {"svg", "g", "a", "switch", "symbol", "defs", "mask", "clipPath", "pattern", "marker"}
|
|
72
|
+
CLIP_SHAPES = {"rect", "circle", "ellipse", "line", "polyline", "polygon", "path", "text", "use"}
|
|
73
|
+
TEXTUAL = {"text", "tspan", "textPath", "title", "desc"}
|
|
74
|
+
INHERITED = (
|
|
75
|
+
"fill", "fill-opacity", "fill-rule", "stroke", "stroke-width", "stroke-opacity",
|
|
76
|
+
"stroke-linecap", "stroke-linejoin", "stroke-dasharray", "stroke-dashoffset",
|
|
77
|
+
"stroke-miterlimit", "font-family", "font-size", "font-weight", "font-style",
|
|
78
|
+
"text-anchor", "color", "visibility", "paint-order", "clip-rule",
|
|
79
|
+
)
|
|
80
|
+
GEOM = {
|
|
81
|
+
"rect": ("x", "y", "width", "height", "rx"),
|
|
82
|
+
"circle": ("cx", "cy", "r"),
|
|
83
|
+
"ellipse": ("cx", "cy", "rx", "ry"),
|
|
84
|
+
"line": ("x1", "y1", "x2", "y2"),
|
|
85
|
+
"polyline": ("points",),
|
|
86
|
+
"polygon": ("points",),
|
|
87
|
+
"path": ("d",),
|
|
88
|
+
"text": ("x", "y"),
|
|
89
|
+
"image": ("x", "y", "width", "height", "href"),
|
|
90
|
+
"use": ("href", "x", "y", "width", "height"),
|
|
91
|
+
"linearGradient": ("x1", "y1", "x2", "y2", "gradientUnits", "href"),
|
|
92
|
+
"radialGradient": ("cx", "cy", "r", "fx", "fy", "gradientUnits", "href"),
|
|
93
|
+
"pattern": ("x", "y", "width", "height", "patternUnits"),
|
|
94
|
+
"stop": ("offset",),
|
|
95
|
+
"mask": ("mask-type",),
|
|
96
|
+
}
|
|
97
|
+
PAINT = (
|
|
98
|
+
"fill", "stroke", "stroke-width", "opacity", "fill-opacity", "stroke-opacity",
|
|
99
|
+
"font-size", "font-family", "stop-color", "stop-opacity", "mask", "clip-path", "filter",
|
|
100
|
+
"marker-start", "marker-end",
|
|
101
|
+
)
|
|
102
|
+
UNITS = ("px", "mm", "cm", "in", "pt", "pc")
|
|
103
|
+
MAX_HISTORY = 100
|
|
104
|
+
MAX_OUTLINE_LINES = 400
|
|
105
|
+
MAX_XML_CHARS = 60_000
|
|
106
|
+
_ID_OK = re.compile(r"^[A-Za-z_][\w.\-]*$")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def is_layer(el) -> bool:
|
|
110
|
+
return is_el(el) and local(el) == "g" and el.get(INK_GROUPMODE) == "layer"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def desc(el) -> str:
|
|
114
|
+
if el is None:
|
|
115
|
+
return "?"
|
|
116
|
+
if is_layer(el):
|
|
117
|
+
return f'layer "{el.get(INK_LABEL) or ""}" #{el.get("id")}'
|
|
118
|
+
eid = el.get("id")
|
|
119
|
+
return f"{local(el)}#{eid}" if eid else f"<{local(el)}>"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def short_val(v: str, limit: int = 48) -> str:
|
|
123
|
+
if v.startswith("data:"):
|
|
124
|
+
head = v.split(",", 1)[0]
|
|
125
|
+
return f"{head}…({len(v) / 1024:.1f}kB)"
|
|
126
|
+
v = " ".join(v.split())
|
|
127
|
+
return v if len(v) <= limit else f"{v[: limit - 4]}…({len(v)})"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def parse_pos(p: str | None) -> tuple[str | None, str | None]:
|
|
131
|
+
if p is None or not str(p).strip():
|
|
132
|
+
return None, None
|
|
133
|
+
p = str(p).strip()
|
|
134
|
+
if p in ("top", "bottom", "up", "down"):
|
|
135
|
+
return p, None
|
|
136
|
+
m = re.match(r"^(before|after)\s*[:=]\s*#?(.+)$", p)
|
|
137
|
+
if m:
|
|
138
|
+
return m.group(1), m.group(2).strip()
|
|
139
|
+
raise EditError("position must be top | bottom | up | down | before:<id> | after:<id>")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def nice_matrix(m) -> str:
|
|
143
|
+
a, b, c, d, e, f = m
|
|
144
|
+
if abs(a - 1) < 1e-9 and abs(b) < 1e-9 and abs(c) < 1e-9 and abs(d - 1) < 1e-9:
|
|
145
|
+
return f"translate({fmt(e)},{fmt(f)})"
|
|
146
|
+
return "matrix(" + ",".join(fmt(x) for x in m) + ")"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def pretty(el, level: int = 0) -> None:
|
|
150
|
+
"""Indent containers only. Never touches text-bearing elements, where added
|
|
151
|
+
whitespace would change rendering."""
|
|
152
|
+
if local(el) in TEXTUAL or local(el) == "style":
|
|
153
|
+
return
|
|
154
|
+
kids = list(el)
|
|
155
|
+
if not kids or (el.text and el.text.strip()):
|
|
156
|
+
return
|
|
157
|
+
if any(k.tail and k.tail.strip() for k in kids):
|
|
158
|
+
return
|
|
159
|
+
pad = "\n" + " " * (level + 1)
|
|
160
|
+
el.text = pad
|
|
161
|
+
for k in kids:
|
|
162
|
+
k.tail = pad
|
|
163
|
+
if is_el(k):
|
|
164
|
+
pretty(k, level + 1)
|
|
165
|
+
kids[-1].tail = "\n" + " " * level
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def elide(root) -> None:
|
|
169
|
+
for e in iter_els(root):
|
|
170
|
+
for k, v in list(e.attrib.items()):
|
|
171
|
+
if v.startswith("data:") and len(v) > 300:
|
|
172
|
+
e.set(k, f"{v[:40]}{ELIDED_MARK} {len(v) - 40} chars⟫")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def resolve_path(p: str) -> Path:
|
|
176
|
+
P = Path(os.path.expandvars(p)).expanduser()
|
|
177
|
+
if not P.is_absolute():
|
|
178
|
+
P = Path(os.environ.get("PAC_VECTOR_ROOT") or os.getcwd()) / P
|
|
179
|
+
return P.resolve()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def mutation(fn):
|
|
183
|
+
@functools.wraps(fn)
|
|
184
|
+
def wrapper(self: "Document", *a, **kw):
|
|
185
|
+
with self.lock:
|
|
186
|
+
snap = self._snap()
|
|
187
|
+
try:
|
|
188
|
+
summary, touched = fn(self, *a, **kw)
|
|
189
|
+
except BaseException:
|
|
190
|
+
self._restore(snap)
|
|
191
|
+
raise
|
|
192
|
+
self.history.append(snap)
|
|
193
|
+
del self.history[:-MAX_HISTORY]
|
|
194
|
+
self.version += 1
|
|
195
|
+
self.dirty = True
|
|
196
|
+
self.touched = [t for t in touched if t]
|
|
197
|
+
if self.on_change:
|
|
198
|
+
self.on_change(self, summary)
|
|
199
|
+
return summary
|
|
200
|
+
|
|
201
|
+
return wrapper
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class Document:
|
|
205
|
+
def __init__(self, doc_id, name, root, lock, on_change=None, path=None):
|
|
206
|
+
self.id = doc_id
|
|
207
|
+
self.name = name
|
|
208
|
+
self.root = root
|
|
209
|
+
self.lock = lock
|
|
210
|
+
self.on_change = on_change
|
|
211
|
+
self.path = path
|
|
212
|
+
self.background: str | None = None
|
|
213
|
+
self.current_layer: str | None = None
|
|
214
|
+
self.history: list[tuple[bytes, dict]] = []
|
|
215
|
+
self.version = 0
|
|
216
|
+
self.dirty = False
|
|
217
|
+
self.touched: list[str] = []
|
|
218
|
+
self._counters: dict[str, int] = {}
|
|
219
|
+
|
|
220
|
+
# ── snapshots ───────────────────────────────────────────────────────────
|
|
221
|
+
def _snap(self):
|
|
222
|
+
props = {"name": self.name, "background": self.background, "current_layer": self.current_layer}
|
|
223
|
+
return etree.tostring(self.root), props
|
|
224
|
+
|
|
225
|
+
def _restore(self, snap):
|
|
226
|
+
data, props = snap
|
|
227
|
+
self.root = from_bytes(data)
|
|
228
|
+
self.name = props["name"]
|
|
229
|
+
self.background = props["background"]
|
|
230
|
+
self.current_layer = props["current_layer"]
|
|
231
|
+
|
|
232
|
+
# ── lookup ──────────────────────────────────────────────────────────────
|
|
233
|
+
def ids(self) -> set[str]:
|
|
234
|
+
return {e.get("id") for e in iter_els(self.root) if e.get("id")}
|
|
235
|
+
|
|
236
|
+
def find(self, eid: str):
|
|
237
|
+
r = self.root.xpath("//*[@id=$i]", i=eid)
|
|
238
|
+
return r[0] if r else None
|
|
239
|
+
|
|
240
|
+
def get(self, ref: str):
|
|
241
|
+
ref = str(ref).strip().lstrip("#")
|
|
242
|
+
if ref == "root":
|
|
243
|
+
return self.root
|
|
244
|
+
el = self.find(ref)
|
|
245
|
+
if el is not None:
|
|
246
|
+
return el
|
|
247
|
+
named = [l for l in self.layers() if l.get(INK_LABEL) == ref]
|
|
248
|
+
if len(named) == 1:
|
|
249
|
+
return named[0]
|
|
250
|
+
close = difflib.get_close_matches(ref, sorted(self.ids()), 5, 0.5)
|
|
251
|
+
hint = f"; did you mean {', '.join('#' + c for c in close)}?" if close else "; inspect lists the ids"
|
|
252
|
+
raise EditError(f"no element #{ref} in {self.id}{hint}")
|
|
253
|
+
|
|
254
|
+
def targets(self, ids) -> list:
|
|
255
|
+
if not ids:
|
|
256
|
+
raise EditError("ids is empty")
|
|
257
|
+
if isinstance(ids, str):
|
|
258
|
+
ids = [ids]
|
|
259
|
+
found = []
|
|
260
|
+
for i in ids:
|
|
261
|
+
el = self.get(i)
|
|
262
|
+
if el not in found:
|
|
263
|
+
found.append(el)
|
|
264
|
+
order = {e: n for n, e in enumerate(self.root.iter())}
|
|
265
|
+
return sorted(found, key=lambda e: order.get(e, 0))
|
|
266
|
+
|
|
267
|
+
def layers(self) -> list:
|
|
268
|
+
return [e for e in iter_els(self.root) if is_layer(e)]
|
|
269
|
+
|
|
270
|
+
def namedview(self):
|
|
271
|
+
for c in children(self.root):
|
|
272
|
+
if local(c) == "namedview":
|
|
273
|
+
return c
|
|
274
|
+
nv = etree.SubElement(self.root, f"{{{SODI_NS}}}namedview")
|
|
275
|
+
self.root.insert(0, nv)
|
|
276
|
+
nv.set("id", "namedview")
|
|
277
|
+
nv.set("pagecolor", "#ffffff")
|
|
278
|
+
nv.set(f"{{{INK_NS}}}pageopacity", "0")
|
|
279
|
+
return nv
|
|
280
|
+
|
|
281
|
+
def defs(self):
|
|
282
|
+
for c in children(self.root):
|
|
283
|
+
if local(c) == "defs":
|
|
284
|
+
return c
|
|
285
|
+
d = etree.SubElement(self.root, q("defs"))
|
|
286
|
+
self.root.insert(self._drawable_start(self.root), d)
|
|
287
|
+
d.set("id", self._auto("defs", self.ids()))
|
|
288
|
+
return d
|
|
289
|
+
|
|
290
|
+
def _drawable_start(self, parent) -> int:
|
|
291
|
+
i = 0
|
|
292
|
+
for c in parent:
|
|
293
|
+
if is_el(c) and is_svg(c) and local(c) not in NON_DRAWABLE:
|
|
294
|
+
break
|
|
295
|
+
i += 1
|
|
296
|
+
return i
|
|
297
|
+
|
|
298
|
+
def current(self):
|
|
299
|
+
if self.current_layer:
|
|
300
|
+
el = self.find(self.current_layer)
|
|
301
|
+
if el is not None and is_layer(el):
|
|
302
|
+
return el
|
|
303
|
+
lays = [c for c in children(self.root) if is_layer(c)]
|
|
304
|
+
if lays:
|
|
305
|
+
return lays[-1]
|
|
306
|
+
return self._new_layer("Layer 1", self.root)
|
|
307
|
+
|
|
308
|
+
def container(self, ref):
|
|
309
|
+
if ref is None:
|
|
310
|
+
return self.current()
|
|
311
|
+
el = self.get(ref)
|
|
312
|
+
if local(el) not in CONTAINERS:
|
|
313
|
+
raise EditError(
|
|
314
|
+
f"{desc(el)} is not a container; to place next to it use position='after:{el.get('id')}'"
|
|
315
|
+
)
|
|
316
|
+
return el
|
|
317
|
+
|
|
318
|
+
# ── ids ─────────────────────────────────────────────────────────────────
|
|
319
|
+
def _auto(self, base: str, taken: set[str]) -> str:
|
|
320
|
+
n = self._counters.get(base, 0) + 1
|
|
321
|
+
while f"{base}-{n}" in taken:
|
|
322
|
+
n += 1
|
|
323
|
+
self._counters[base] = n
|
|
324
|
+
return f"{base}-{n}"
|
|
325
|
+
|
|
326
|
+
def _unique(self, eid: str, taken: set[str]) -> str:
|
|
327
|
+
m = re.match(r"^(.*?)-(\d+)$", eid)
|
|
328
|
+
return self._auto(m.group(1) if m else eid, taken)
|
|
329
|
+
|
|
330
|
+
def _wants_id(self, e) -> bool:
|
|
331
|
+
ln = local(e)
|
|
332
|
+
return is_svg(e) and ln not in AUTO_ID_SKIP and not ln.startswith("fe")
|
|
333
|
+
|
|
334
|
+
def assign_ids(self, tops, taken: set[str] | None = None) -> dict[str, str]:
|
|
335
|
+
if taken is None:
|
|
336
|
+
# tops may already sit in this tree (import, a mask source being moved):
|
|
337
|
+
# their own ids must not count as taken, or they'd rename themselves.
|
|
338
|
+
inside = {e for t in tops for e in iter_els(t)}
|
|
339
|
+
taken = {e.get("id") for e in iter_els(self.root) if e.get("id") and e not in inside}
|
|
340
|
+
renames: dict[str, str] = {}
|
|
341
|
+
for top in tops:
|
|
342
|
+
for e in iter_els(top):
|
|
343
|
+
if not is_svg(e) or e is self.root:
|
|
344
|
+
continue
|
|
345
|
+
eid = e.get("id")
|
|
346
|
+
if eid:
|
|
347
|
+
if eid in taken:
|
|
348
|
+
new = self._unique(eid, taken)
|
|
349
|
+
renames[eid] = new
|
|
350
|
+
e.set("id", new)
|
|
351
|
+
taken.add(e.get("id"))
|
|
352
|
+
elif self._wants_id(e):
|
|
353
|
+
new = self._auto("layer" if is_layer(e) else local(e), taken)
|
|
354
|
+
e.set("id", new)
|
|
355
|
+
taken.add(new)
|
|
356
|
+
rewrite_refs(tops, renames)
|
|
357
|
+
return renames
|
|
358
|
+
|
|
359
|
+
def _rename(self, e, new) -> None:
|
|
360
|
+
new = str(new).strip().lstrip("#")
|
|
361
|
+
if not _ID_OK.match(new):
|
|
362
|
+
raise EditError(f"invalid id '{new}' (letters, digits, _ . -; must not start with a digit)")
|
|
363
|
+
old = e.get("id")
|
|
364
|
+
if new == old:
|
|
365
|
+
return
|
|
366
|
+
if self.find(new) is not None:
|
|
367
|
+
raise EditError(f"id #{new} is already taken")
|
|
368
|
+
e.set("id", new)
|
|
369
|
+
if old:
|
|
370
|
+
rewrite_refs([self.root], {old: new})
|
|
371
|
+
if self.current_layer == old:
|
|
372
|
+
self.current_layer = new
|
|
373
|
+
|
|
374
|
+
# ── checks & notes ──────────────────────────────────────────────────────
|
|
375
|
+
def check_unlocked(self, el) -> None:
|
|
376
|
+
for a in [el, *el.iterancestors()]:
|
|
377
|
+
if a.get(SODI_INSENSITIVE) == "true":
|
|
378
|
+
inside = "" if a is el else f" (inside locked {desc(a)})"
|
|
379
|
+
raise EditError(
|
|
380
|
+
f"{desc(el)} is locked{inside}; unlock with update(ids=['{a.get('id')}'], locked=false)"
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
def dangling(self, tops) -> list[str]:
|
|
384
|
+
ids = self.ids()
|
|
385
|
+
out = []
|
|
386
|
+
for top in tops:
|
|
387
|
+
for e in iter_els(top):
|
|
388
|
+
for r in sorted(refs_of(e) - ids):
|
|
389
|
+
out.append(f"{desc(e)} references missing #{r}")
|
|
390
|
+
return out
|
|
391
|
+
|
|
392
|
+
def _place(self, parent, els, kind, ref) -> None:
|
|
393
|
+
if kind in (None, "top"):
|
|
394
|
+
for e in els:
|
|
395
|
+
parent.append(e)
|
|
396
|
+
elif kind == "bottom":
|
|
397
|
+
i = self._drawable_start(parent)
|
|
398
|
+
for n, e in enumerate(els):
|
|
399
|
+
parent.insert(i + n, e)
|
|
400
|
+
elif kind == "before":
|
|
401
|
+
r = self.get(ref)
|
|
402
|
+
for e in els:
|
|
403
|
+
r.addprevious(e)
|
|
404
|
+
elif kind == "after":
|
|
405
|
+
anchor = self.get(ref)
|
|
406
|
+
for e in els:
|
|
407
|
+
anchor.addnext(e)
|
|
408
|
+
anchor = e
|
|
409
|
+
else:
|
|
410
|
+
raise EditError(f"position '{kind}' is not valid here")
|
|
411
|
+
|
|
412
|
+
def _new_layer(self, label, parent, kind="top", ref=None):
|
|
413
|
+
g = etree.SubElement(parent, q("g"))
|
|
414
|
+
g.set(INK_GROUPMODE, "layer")
|
|
415
|
+
g.set(INK_LABEL, label)
|
|
416
|
+
g.set("id", self._auto("layer", self.ids()))
|
|
417
|
+
if kind not in (None, "top"):
|
|
418
|
+
self._place(parent, [g], kind, ref)
|
|
419
|
+
return g
|
|
420
|
+
|
|
421
|
+
def _route(self, els) -> tuple[list, list]:
|
|
422
|
+
"""Split parsed elements into drawing elements and <defs> content."""
|
|
423
|
+
body, defs_added = [], []
|
|
424
|
+
for e in els:
|
|
425
|
+
if local(e) == "defs":
|
|
426
|
+
for c in children(e):
|
|
427
|
+
self.defs().append(c)
|
|
428
|
+
defs_added.append(c)
|
|
429
|
+
elif local(e) in DEFS_TAGS:
|
|
430
|
+
self.defs().append(e)
|
|
431
|
+
defs_added.append(e)
|
|
432
|
+
else:
|
|
433
|
+
body.append(e)
|
|
434
|
+
return body, defs_added
|
|
435
|
+
|
|
436
|
+
def normalise_layers(self) -> int:
|
|
437
|
+
"""If the drawing has no layers, wrap its loose elements into 'Layer 1'."""
|
|
438
|
+
drawables = [c for c in children(self.root) if is_svg(c) and local(c) not in NON_DRAWABLE]
|
|
439
|
+
if any(is_layer(c) for c in drawables):
|
|
440
|
+
return 0
|
|
441
|
+
lay = self._new_layer("Layer 1", self.root)
|
|
442
|
+
for c in drawables:
|
|
443
|
+
lay.append(c)
|
|
444
|
+
return len(drawables)
|
|
445
|
+
|
|
446
|
+
def set_background(self, bg: str | None) -> None:
|
|
447
|
+
self.background = bg
|
|
448
|
+
nv = self.namedview()
|
|
449
|
+
if bg:
|
|
450
|
+
nv.set("pagecolor", bg)
|
|
451
|
+
nv.set(f"{{{INK_NS}}}pageopacity", "1")
|
|
452
|
+
else:
|
|
453
|
+
nv.set(f"{{{INK_NS}}}pageopacity", "0")
|
|
454
|
+
|
|
455
|
+
def _center(self, el) -> tuple[float, float]:
|
|
456
|
+
"""Visual center of `el`, in its parent's coordinates."""
|
|
457
|
+
box = R.measure(R.isolate(self.root, {el.get("id")}))
|
|
458
|
+
if not box:
|
|
459
|
+
raise EditError(f"{desc(el)} has no visible pixels to find a center; pass origin=[x, y]")
|
|
460
|
+
cx, cy = box[0] + box[2] / 2, box[1] + box[3] / 2
|
|
461
|
+
return apply(inv(parent_ctm(el)), cx, cy)
|
|
462
|
+
|
|
463
|
+
def _move(self, el, parent, kind, ref) -> None:
|
|
464
|
+
"""Move el keeping its on-canvas position (compensates parent transforms)."""
|
|
465
|
+
old = parent_ctm(el)
|
|
466
|
+
new = el_ctm(parent)
|
|
467
|
+
self._place(parent, [el], kind, ref)
|
|
468
|
+
if not same(old, new):
|
|
469
|
+
prepend_transform(el, nice_matrix(mul(inv(new), old)))
|
|
470
|
+
|
|
471
|
+
# ── info ────────────────────────────────────────────────────────────────
|
|
472
|
+
def element_count(self) -> int:
|
|
473
|
+
n = 0
|
|
474
|
+
for e in iter_els(self.root):
|
|
475
|
+
if e is self.root or not is_svg(e) or local(e) in NON_DRAWABLE:
|
|
476
|
+
continue
|
|
477
|
+
if any(local(a) == "defs" for a in e.iterancestors()):
|
|
478
|
+
continue
|
|
479
|
+
n += 1
|
|
480
|
+
return n
|
|
481
|
+
|
|
482
|
+
def info(self) -> str:
|
|
483
|
+
r = self.root
|
|
484
|
+
lays = ", ".join(f'"{l.get(INK_LABEL)}" #{l.get("id")}' for l in self.layers()) or "none"
|
|
485
|
+
cur = self.current_layer or "-"
|
|
486
|
+
parts = [
|
|
487
|
+
f'{self.id} "{self.name}"',
|
|
488
|
+
f'{r.get("width")}×{r.get("height")}',
|
|
489
|
+
f'viewBox {r.get("viewBox") or "-"}',
|
|
490
|
+
f"bg {self.background or 'none'}",
|
|
491
|
+
f"{self.element_count()} elements",
|
|
492
|
+
f"layers {lays} (current #{cur})",
|
|
493
|
+
f"v{self.version}" + (" unsaved" if self.dirty else ""),
|
|
494
|
+
]
|
|
495
|
+
if self.path:
|
|
496
|
+
parts.append(self.path)
|
|
497
|
+
return " · ".join(parts)
|
|
498
|
+
|
|
499
|
+
def payload(self, summary: str = "") -> dict:
|
|
500
|
+
return {
|
|
501
|
+
"type": "doc",
|
|
502
|
+
"id": self.id,
|
|
503
|
+
"name": self.name,
|
|
504
|
+
"svg": etree.tostring(self.root, encoding="unicode"),
|
|
505
|
+
"background": self.background,
|
|
506
|
+
"version": self.version,
|
|
507
|
+
"touched": self.touched,
|
|
508
|
+
"summary": summary,
|
|
509
|
+
"info": self.info(),
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
# ── reading ─────────────────────────────────────────────────────────────
|
|
513
|
+
def _line(self, el) -> str:
|
|
514
|
+
if not is_svg(el):
|
|
515
|
+
return short_attr(el.tag)
|
|
516
|
+
ln = local(el)
|
|
517
|
+
parts = [desc(el)]
|
|
518
|
+
label = el.get(INK_LABEL)
|
|
519
|
+
if label and not is_layer(el):
|
|
520
|
+
parts.append(f'"{label}"')
|
|
521
|
+
for a in GEOM.get(ln, ()):
|
|
522
|
+
v = href(el) if a == "href" else el.get(a)
|
|
523
|
+
if v is not None:
|
|
524
|
+
parts.append(f"{a}={short_val(v)}")
|
|
525
|
+
for p in PAINT:
|
|
526
|
+
v = get_prop(el, p)
|
|
527
|
+
if v is not None:
|
|
528
|
+
parts.append(f"{p}={short_val(v)}")
|
|
529
|
+
if ln in ("text", "textPath"):
|
|
530
|
+
t = " ".join("".join(el.itertext()).split())
|
|
531
|
+
parts.append(repr(t if len(t) <= 40 else t[:37] + "…"))
|
|
532
|
+
if el.get("transform"):
|
|
533
|
+
parts.append(f"transform={short_val(el.get('transform'))}")
|
|
534
|
+
if get_prop(el, "display") == "none":
|
|
535
|
+
parts.append("HIDDEN")
|
|
536
|
+
if el.get(SODI_INSENSITIVE) == "true":
|
|
537
|
+
parts.append("LOCKED")
|
|
538
|
+
n = len(children(el))
|
|
539
|
+
if n and ln not in ("text",):
|
|
540
|
+
parts.append(f"[{n}]")
|
|
541
|
+
return " ".join(parts)
|
|
542
|
+
|
|
543
|
+
def inspect(self, root_ref=None, depth=3, fmt_="outline") -> str:
|
|
544
|
+
with self.lock:
|
|
545
|
+
top = self.root if root_ref is None else self.get(root_ref)
|
|
546
|
+
if fmt_ == "xml":
|
|
547
|
+
clone = copy.deepcopy(top)
|
|
548
|
+
elide(clone)
|
|
549
|
+
pretty(clone)
|
|
550
|
+
s = etree.tostring(clone, encoding="unicode")
|
|
551
|
+
if len(s) > MAX_XML_CHARS:
|
|
552
|
+
s = s[:MAX_XML_CHARS] + f"\n… truncated ({len(s)} chars); inspect a smaller root"
|
|
553
|
+
return s
|
|
554
|
+
if fmt_ != "outline":
|
|
555
|
+
raise EditError("format must be 'outline' or 'xml'")
|
|
556
|
+
lines = [self.info()] if top is self.root else [self._line(top)]
|
|
557
|
+
base = 0 if top is self.root else 1
|
|
558
|
+
|
|
559
|
+
def walk(el, d):
|
|
560
|
+
for c in children(el):
|
|
561
|
+
if local(c) == "namedview":
|
|
562
|
+
continue
|
|
563
|
+
lines.append(" " * (d + base) + self._line(c))
|
|
564
|
+
if children(c) and local(c) not in ("text",) and d + 1 < depth:
|
|
565
|
+
walk(c, d + 1)
|
|
566
|
+
|
|
567
|
+
walk(top, 0)
|
|
568
|
+
if len(lines) > MAX_OUTLINE_LINES:
|
|
569
|
+
extra = len(lines) - MAX_OUTLINE_LINES
|
|
570
|
+
lines = lines[:MAX_OUTLINE_LINES] + [f"… {extra} more lines; inspect(root=<id>) to drill in"]
|
|
571
|
+
return "\n".join(lines)
|
|
572
|
+
|
|
573
|
+
def render(self, ids=None, scale=1.0, padding=16, background=True, isolate=False):
|
|
574
|
+
with self.lock:
|
|
575
|
+
root = copy.deepcopy(self.root)
|
|
576
|
+
els = self.targets(ids) if ids else []
|
|
577
|
+
bg = self.background if background else None
|
|
578
|
+
resdir = str(Path(self.path).parent) if self.path else None
|
|
579
|
+
if not els:
|
|
580
|
+
png, w, h, s = R.render_page(root, scale, bg, resdir)
|
|
581
|
+
return f"page {w}×{h}px (scale {s:g})", png
|
|
582
|
+
idset = {e.get("id") for e in els}
|
|
583
|
+
iso = R.isolate(root, idset)
|
|
584
|
+
box = R.measure(iso)
|
|
585
|
+
if not box:
|
|
586
|
+
raise EditError(f"nothing visible for {', '.join('#' + i for i in idset)} (hidden or transparent?)")
|
|
587
|
+
W, _ = R.size_px(root)
|
|
588
|
+
vb = R.viewbox(root)
|
|
589
|
+
ppu = W / vb[2] * scale
|
|
590
|
+
pad = padding / ppu
|
|
591
|
+
region = (box[0] - pad, box[1] - pad, box[2] + 2 * pad, box[3] + 2 * pad)
|
|
592
|
+
pw, ph = region[2] * ppu, region[3] * ppu
|
|
593
|
+
if max(pw, ph) > R.MAX_SIDE:
|
|
594
|
+
k = R.MAX_SIDE / max(pw, ph)
|
|
595
|
+
pw, ph = pw * k, ph * k
|
|
596
|
+
pw, ph = max(1, round(pw)), max(1, round(ph))
|
|
597
|
+
png = R.render_region(iso if isolate else root, region, pw, ph, bg, resdir)
|
|
598
|
+
bb = " ".join(f"{k}={fmt(round(v, 1))}" for k, v in zip("xywh", box)) # 0.1: below AA noise
|
|
599
|
+
return f"{', '.join('#' + i for i in idset)} bbox {bb} (user units) · {pw}×{ph}px", png
|
|
600
|
+
|
|
601
|
+
# ── editing ─────────────────────────────────────────────────────────────
|
|
602
|
+
@mutation
|
|
603
|
+
def add(self, markup, parent=None, position=None):
|
|
604
|
+
els = parse_fragment(markup)
|
|
605
|
+
notes = sanitize(els)
|
|
606
|
+
kind, ref = parse_pos(position)
|
|
607
|
+
if kind in ("up", "down"):
|
|
608
|
+
raise EditError("position up/down is for arrange; add takes top | bottom | before:<id> | after:<id>")
|
|
609
|
+
if kind in ("before", "after"):
|
|
610
|
+
anchor = self.get(ref)
|
|
611
|
+
cont = anchor.getparent()
|
|
612
|
+
if parent is not None and cont is not self.container(parent):
|
|
613
|
+
raise EditError(f"#{ref} is not inside #{parent}")
|
|
614
|
+
else:
|
|
615
|
+
cont = self.container(parent)
|
|
616
|
+
if cont is not self.root:
|
|
617
|
+
self.check_unlocked(cont)
|
|
618
|
+
renames = self.assign_ids(els)
|
|
619
|
+
body, defs_added = self._route(els)
|
|
620
|
+
if body:
|
|
621
|
+
self._place(cont, body, kind, ref)
|
|
622
|
+
if is_layer(cont):
|
|
623
|
+
self.current_layer = cont.get("id")
|
|
624
|
+
out = []
|
|
625
|
+
if body:
|
|
626
|
+
out.append(f"added {len(body)} to {desc(cont)}: " + ", ".join(desc(e) for e in body))
|
|
627
|
+
if defs_added:
|
|
628
|
+
out.append("defs: " + ", ".join(desc(e) for e in defs_added))
|
|
629
|
+
if renames:
|
|
630
|
+
out.append("renamed (id taken): " + ", ".join(f"{a}→{b}" for a, b in renames.items()))
|
|
631
|
+
out += self.dangling(body + defs_added) + notes
|
|
632
|
+
return "\n".join(out), [e.get("id") for e in body + defs_added]
|
|
633
|
+
|
|
634
|
+
@mutation
|
|
635
|
+
def update(self, ids, attrs=None, style=None, text=None, transform=None, label=None, hidden=None, locked=None):
|
|
636
|
+
els = self.targets(ids)
|
|
637
|
+
attrs = dict(attrs or {})
|
|
638
|
+
if not (attrs or style or text is not None or transform is not None
|
|
639
|
+
or label is not None or hidden is not None or locked is not None):
|
|
640
|
+
raise EditError("nothing to update: pass attrs, style, text, transform, label, hidden or locked")
|
|
641
|
+
if "id" in attrs and len(els) != 1:
|
|
642
|
+
raise EditError("renaming (attrs.id) takes exactly one element")
|
|
643
|
+
changed: set[str] = set()
|
|
644
|
+
for e in els:
|
|
645
|
+
if locked is not False:
|
|
646
|
+
self.check_unlocked(e)
|
|
647
|
+
for k, v in attrs.items():
|
|
648
|
+
if k == "id":
|
|
649
|
+
self._rename(e, v)
|
|
650
|
+
changed.add("id")
|
|
651
|
+
continue
|
|
652
|
+
key = attr_name(k)
|
|
653
|
+
if etree.QName(key).localname == "href": # keep one href spelling
|
|
654
|
+
for alt in ("href", attr_name("xlink:href")):
|
|
655
|
+
if alt != key:
|
|
656
|
+
e.attrib.pop(alt, None)
|
|
657
|
+
if v is None:
|
|
658
|
+
e.attrib.pop(key, None)
|
|
659
|
+
else:
|
|
660
|
+
e.set(key, fmt(v))
|
|
661
|
+
changed.add(k)
|
|
662
|
+
if style:
|
|
663
|
+
st = parse_style(e.get("style"))
|
|
664
|
+
for k, v in style.items():
|
|
665
|
+
if v is None:
|
|
666
|
+
st.pop(k, None)
|
|
667
|
+
e.attrib.pop(k, None)
|
|
668
|
+
else:
|
|
669
|
+
st[k] = fmt(v)
|
|
670
|
+
changed.add(k)
|
|
671
|
+
if st:
|
|
672
|
+
e.set("style", style_str(st))
|
|
673
|
+
else:
|
|
674
|
+
e.attrib.pop("style", None)
|
|
675
|
+
if text is not None:
|
|
676
|
+
if local(e) not in TEXTUAL:
|
|
677
|
+
raise EditError(f"{desc(e)} holds no text (text works on text/tspan/textPath/title/desc)")
|
|
678
|
+
for c in list(e):
|
|
679
|
+
e.remove(c)
|
|
680
|
+
e.text = text
|
|
681
|
+
changed.add("text")
|
|
682
|
+
if transform is not None:
|
|
683
|
+
self._apply_transform(e, transform)
|
|
684
|
+
changed.add("transform")
|
|
685
|
+
if label is not None:
|
|
686
|
+
if label:
|
|
687
|
+
e.set(INK_LABEL, label)
|
|
688
|
+
else:
|
|
689
|
+
e.attrib.pop(INK_LABEL, None)
|
|
690
|
+
changed.add("label")
|
|
691
|
+
if hidden is not None:
|
|
692
|
+
st = parse_style(e.get("style"))
|
|
693
|
+
st.pop("display", None)
|
|
694
|
+
e.attrib.pop("display", None)
|
|
695
|
+
if hidden:
|
|
696
|
+
st["display"] = "none"
|
|
697
|
+
if st:
|
|
698
|
+
e.set("style", style_str(st))
|
|
699
|
+
else:
|
|
700
|
+
e.attrib.pop("style", None)
|
|
701
|
+
changed.add("hidden")
|
|
702
|
+
if locked is not None:
|
|
703
|
+
if locked:
|
|
704
|
+
e.set(SODI_INSENSITIVE, "true")
|
|
705
|
+
else:
|
|
706
|
+
e.attrib.pop(SODI_INSENSITIVE, None)
|
|
707
|
+
changed.add("locked")
|
|
708
|
+
out = [f"updated {len(els)}: {', '.join(desc(e) for e in els)} ({', '.join(sorted(changed))})"]
|
|
709
|
+
out += self.dangling(els)
|
|
710
|
+
return "\n".join(out), [e.get("id") for e in els]
|
|
711
|
+
|
|
712
|
+
def _apply_transform(self, e, spec) -> None:
|
|
713
|
+
if isinstance(spec, str):
|
|
714
|
+
s = spec.strip()
|
|
715
|
+
if s and re.sub(r"(matrix|translate|scale|rotate|skewX|skewY)\s*\([^)]*\)", "", s).strip(" ,"):
|
|
716
|
+
raise EditError(f"unrecognised transform '{spec}'")
|
|
717
|
+
if s:
|
|
718
|
+
e.set("transform", s)
|
|
719
|
+
else:
|
|
720
|
+
e.attrib.pop("transform", None)
|
|
721
|
+
return
|
|
722
|
+
spec = {k: v for k, v in dict(spec).items() if v is not None}
|
|
723
|
+
mode = spec.get("mode", "append")
|
|
724
|
+
if mode not in ("append", "set"):
|
|
725
|
+
raise EditError("transform.mode must be 'append' or 'set'")
|
|
726
|
+
tr, rot, sc = spec.get("translate"), spec.get("rotate"), spec.get("scale")
|
|
727
|
+
if tr is None and rot is None and sc is None:
|
|
728
|
+
raise EditError("transform needs translate, rotate and/or scale")
|
|
729
|
+
saved = e.get("transform")
|
|
730
|
+
if mode == "set":
|
|
731
|
+
e.attrib.pop("transform", None)
|
|
732
|
+
ox = oy = 0.0
|
|
733
|
+
if rot is not None or sc is not None:
|
|
734
|
+
origin = spec.get("origin", "center")
|
|
735
|
+
if origin == "center":
|
|
736
|
+
ox, oy = self._center(e)
|
|
737
|
+
elif isinstance(origin, (list, tuple)) and len(origin) == 2:
|
|
738
|
+
ox, oy = float(origin[0]), float(origin[1])
|
|
739
|
+
else:
|
|
740
|
+
raise EditError("transform.origin must be 'center' or [x, y]")
|
|
741
|
+
parts = []
|
|
742
|
+
if tr is not None:
|
|
743
|
+
if isinstance(tr, (int, float)) or len(tr) != 2:
|
|
744
|
+
raise EditError("transform.translate must be [dx, dy]")
|
|
745
|
+
parts.append(f"translate({fmt(tr[0])},{fmt(tr[1])})")
|
|
746
|
+
if rot is not None:
|
|
747
|
+
parts.append(f"rotate({fmt(rot)},{fmt(ox)},{fmt(oy)})")
|
|
748
|
+
if sc is not None:
|
|
749
|
+
sx, sy = (sc, sc) if isinstance(sc, (int, float)) else (sc[0], sc[1])
|
|
750
|
+
if ox or oy:
|
|
751
|
+
parts.append(f"translate({fmt(ox)},{fmt(oy)}) scale({fmt(sx)},{fmt(sy)}) translate({fmt(-ox)},{fmt(-oy)})")
|
|
752
|
+
else:
|
|
753
|
+
parts.append(f"scale({fmt(sx)},{fmt(sy)})")
|
|
754
|
+
try:
|
|
755
|
+
prepend_transform(e, " ".join(parts))
|
|
756
|
+
except EditError:
|
|
757
|
+
if saved is not None:
|
|
758
|
+
e.set("transform", saved)
|
|
759
|
+
raise
|
|
760
|
+
|
|
761
|
+
@mutation
|
|
762
|
+
def replace(self, markup, target=None):
|
|
763
|
+
els = parse_fragment(markup)
|
|
764
|
+
notes = sanitize(els)
|
|
765
|
+
t = None if target is None else self.get(target)
|
|
766
|
+
if t is None or t is self.root:
|
|
767
|
+
return self._replace_all(els, notes)
|
|
768
|
+
self.check_unlocked(t)
|
|
769
|
+
if t is self.namedview():
|
|
770
|
+
raise EditError("the namedview holds document settings; use doc_set")
|
|
771
|
+
if len(els) == 1 and not els[0].get("id"):
|
|
772
|
+
els[0].set("id", t.get("id"))
|
|
773
|
+
freed = {e.get("id") for e in iter_els(t) if e.get("id")}
|
|
774
|
+
renames = self.assign_ids(els, self.ids() - freed)
|
|
775
|
+
for e in els:
|
|
776
|
+
t.addprevious(e)
|
|
777
|
+
t.getparent().remove(t)
|
|
778
|
+
out = [f"replaced #{target} with " + ", ".join(desc(e) for e in els)]
|
|
779
|
+
if renames:
|
|
780
|
+
out.append("renamed (id taken): " + ", ".join(f"{a}→{b}" for a, b in renames.items()))
|
|
781
|
+
gone = freed - self.ids()
|
|
782
|
+
for e in iter_els(self.root):
|
|
783
|
+
for r in sorted(refs_of(e) & gone):
|
|
784
|
+
out.append(f"{desc(e)} still references removed #{r}")
|
|
785
|
+
out += self.dangling(els) + notes
|
|
786
|
+
return "\n".join(out), [e.get("id") for e in els]
|
|
787
|
+
|
|
788
|
+
def _replace_all(self, els, notes):
|
|
789
|
+
if len(els) == 1 and local(els[0]) == "svg":
|
|
790
|
+
src = els[0]
|
|
791
|
+
for a in ("width", "height", "viewBox"):
|
|
792
|
+
if src.get(a):
|
|
793
|
+
self.root.set(a, src.get(a))
|
|
794
|
+
els = children(src)
|
|
795
|
+
nv = self.namedview()
|
|
796
|
+
for c in list(self.root):
|
|
797
|
+
if c is not nv:
|
|
798
|
+
self.root.remove(c)
|
|
799
|
+
for e in els:
|
|
800
|
+
e.tail = None
|
|
801
|
+
renames = self.assign_ids(els, self.ids())
|
|
802
|
+
body, defs_added = self._route(els)
|
|
803
|
+
for e in body:
|
|
804
|
+
self.root.append(e)
|
|
805
|
+
wrapped = self.normalise_layers()
|
|
806
|
+
self.current_layer = None
|
|
807
|
+
out = [f"replaced the whole drawing: {len(body)} elements, {len(defs_added)} defs"]
|
|
808
|
+
if wrapped:
|
|
809
|
+
out.append(f'wrapped {wrapped} loose elements into layer "Layer 1"')
|
|
810
|
+
if renames:
|
|
811
|
+
out.append("renamed duplicate ids: " + ", ".join(f"{a}→{b}" for a, b in renames.items()))
|
|
812
|
+
out += self.dangling([self.root]) + notes
|
|
813
|
+
return "\n".join(out), [e.get("id") for e in body]
|
|
814
|
+
|
|
815
|
+
@mutation
|
|
816
|
+
def remove(self, ids):
|
|
817
|
+
els = self.targets(ids)
|
|
818
|
+
for e in els:
|
|
819
|
+
if e is self.root or local(e) in ("namedview", "defs"):
|
|
820
|
+
raise EditError(f"{desc(e)} can't be removed")
|
|
821
|
+
self.check_unlocked(e)
|
|
822
|
+
removed = {x.get("id") for e in els for x in iter_els(e) if x.get("id")}
|
|
823
|
+
names = [desc(e) for e in els]
|
|
824
|
+
for e in els:
|
|
825
|
+
p = e.getparent()
|
|
826
|
+
if p is not None:
|
|
827
|
+
p.remove(e)
|
|
828
|
+
if self.current_layer in removed:
|
|
829
|
+
self.current_layer = None
|
|
830
|
+
out = [f"removed {len(els)}: {', '.join(names)}"]
|
|
831
|
+
for e in iter_els(self.root):
|
|
832
|
+
for r in sorted(refs_of(e) & removed):
|
|
833
|
+
out.append(f"{desc(e)} still references removed #{r}")
|
|
834
|
+
return "\n".join(out), []
|
|
835
|
+
|
|
836
|
+
@mutation
|
|
837
|
+
def arrange(self, ids, to=None, position=None, copy_=False, offset=None):
|
|
838
|
+
els = self.targets(ids)
|
|
839
|
+
for e in els:
|
|
840
|
+
if e is self.root or local(e) in ("namedview", "defs"):
|
|
841
|
+
raise EditError(f"{desc(e)} can't be arranged")
|
|
842
|
+
if not copy_:
|
|
843
|
+
self.check_unlocked(e)
|
|
844
|
+
kind, ref = parse_pos(position)
|
|
845
|
+
dest = self.container(to) if to is not None else None
|
|
846
|
+
if dest is not None:
|
|
847
|
+
if kind in ("up", "down"):
|
|
848
|
+
raise EditError("up/down steps within the current parent; drop `to`")
|
|
849
|
+
if dest is not self.root:
|
|
850
|
+
self.check_unlocked(dest)
|
|
851
|
+
for e in els:
|
|
852
|
+
if dest is e or e in dest.iterancestors():
|
|
853
|
+
raise EditError(f"can't move {desc(e)} into itself")
|
|
854
|
+
if ref is not None:
|
|
855
|
+
r = self.get(ref)
|
|
856
|
+
if r in els:
|
|
857
|
+
raise EditError(f"#{ref} is one of the elements being arranged")
|
|
858
|
+
if dest is not None and r.getparent() is not dest:
|
|
859
|
+
raise EditError(f"#{ref} is not a child of #{to}")
|
|
860
|
+
if kind is None and dest is None and not copy_:
|
|
861
|
+
raise EditError("nothing to do: pass to=<container> and/or position=")
|
|
862
|
+
if kind in ("up", "down") and copy_:
|
|
863
|
+
raise EditError("up/down is for moves, not copies")
|
|
864
|
+
|
|
865
|
+
if kind in ("up", "down"):
|
|
866
|
+
sel = set(els)
|
|
867
|
+
seq = reversed(els) if kind == "up" else els
|
|
868
|
+
for e in seq:
|
|
869
|
+
n = e.getnext() if kind == "up" else e.getprevious()
|
|
870
|
+
while n is not None and not is_el(n):
|
|
871
|
+
n = n.getnext() if kind == "up" else n.getprevious()
|
|
872
|
+
if n is None or n in sel or local(n) in NON_DRAWABLE:
|
|
873
|
+
continue
|
|
874
|
+
(n.addnext if kind == "up" else n.addprevious)(e)
|
|
875
|
+
return f"moved {kind}: {', '.join(desc(e) for e in els)}", [e.get("id") for e in els]
|
|
876
|
+
|
|
877
|
+
nodes = els
|
|
878
|
+
if copy_:
|
|
879
|
+
nodes = []
|
|
880
|
+
for e in els:
|
|
881
|
+
c = copy.deepcopy(e)
|
|
882
|
+
c.tail = None
|
|
883
|
+
nodes.append(c)
|
|
884
|
+
self.assign_ids(nodes)
|
|
885
|
+
anchor_after = self.get(ref) if kind == "after" else None
|
|
886
|
+
bottom_count: dict = {}
|
|
887
|
+
for orig, node in zip(els, nodes):
|
|
888
|
+
old = parent_ctm(orig)
|
|
889
|
+
if kind is None and dest is None: # copy beside its original
|
|
890
|
+
orig.addnext(node)
|
|
891
|
+
continue
|
|
892
|
+
parent = dest if dest is not None else (
|
|
893
|
+
self.get(ref).getparent() if kind in ("before", "after") else orig.getparent()
|
|
894
|
+
)
|
|
895
|
+
new = el_ctm(parent)
|
|
896
|
+
if kind == "after":
|
|
897
|
+
anchor_after.addnext(node)
|
|
898
|
+
anchor_after = node
|
|
899
|
+
elif kind == "bottom":
|
|
900
|
+
i = self._drawable_start(parent) + bottom_count.get(parent, 0)
|
|
901
|
+
bottom_count[parent] = bottom_count.get(parent, 0) + 1
|
|
902
|
+
parent.insert(i, node)
|
|
903
|
+
elif kind == "before":
|
|
904
|
+
self.get(ref).addprevious(node)
|
|
905
|
+
else:
|
|
906
|
+
parent.append(node)
|
|
907
|
+
if not same(old, new):
|
|
908
|
+
prepend_transform(node, nice_matrix(mul(inv(new), old)))
|
|
909
|
+
if offset:
|
|
910
|
+
if len(offset) != 2:
|
|
911
|
+
raise EditError("offset must be [dx, dy]")
|
|
912
|
+
for node in nodes:
|
|
913
|
+
prepend_transform(node, f"translate({fmt(offset[0])},{fmt(offset[1])})")
|
|
914
|
+
where = desc(dest) if dest is not None else (f"{kind}:{ref}" if ref else (kind or "beside originals"))
|
|
915
|
+
verb = "copied" if copy_ else "moved"
|
|
916
|
+
return f"{verb} {len(nodes)} → {where}: {', '.join(desc(n) for n in nodes)}", [n.get("id") for n in nodes]
|
|
917
|
+
|
|
918
|
+
@mutation
|
|
919
|
+
def group(self, ids=None, label=None, layer=False, parent=None, position=None):
|
|
920
|
+
kind, ref = parse_pos(position)
|
|
921
|
+
if kind in ("up", "down"):
|
|
922
|
+
raise EditError("group position takes top | bottom | before:<id> | after:<id>")
|
|
923
|
+
if not ids:
|
|
924
|
+
dest = self.container(parent) if parent is not None else (self.root if layer else self.current())
|
|
925
|
+
if dest is not self.root:
|
|
926
|
+
self.check_unlocked(dest)
|
|
927
|
+
if layer:
|
|
928
|
+
n = len(self.layers()) + 1
|
|
929
|
+
g = self._new_layer(label or f"Layer {n}", dest, kind, ref)
|
|
930
|
+
self.current_layer = g.get("id")
|
|
931
|
+
else:
|
|
932
|
+
g = etree.SubElement(dest, q("g"))
|
|
933
|
+
g.set("id", self._auto("g", self.ids()))
|
|
934
|
+
if label:
|
|
935
|
+
g.set(INK_LABEL, label)
|
|
936
|
+
if kind not in (None, "top"):
|
|
937
|
+
self._place(dest, [g], kind, ref)
|
|
938
|
+
return f"created empty {desc(g)} in {desc(dest)}", [g.get("id")]
|
|
939
|
+
els = self.targets(ids)
|
|
940
|
+
for e in els:
|
|
941
|
+
if e is self.root or local(e) in NON_DRAWABLE:
|
|
942
|
+
raise EditError(f"{desc(e)} can't be grouped")
|
|
943
|
+
self.check_unlocked(e)
|
|
944
|
+
anchor = els[-1]
|
|
945
|
+
dest = self.container(parent) if parent is not None else anchor.getparent()
|
|
946
|
+
if dest is not self.root:
|
|
947
|
+
self.check_unlocked(dest)
|
|
948
|
+
g = etree.SubElement(dest, q("g"))
|
|
949
|
+
if kind is not None:
|
|
950
|
+
self._place(dest, [g], kind, ref)
|
|
951
|
+
elif anchor.getparent() is dest:
|
|
952
|
+
anchor.addnext(g)
|
|
953
|
+
if layer:
|
|
954
|
+
g.set(INK_GROUPMODE, "layer")
|
|
955
|
+
g.set(INK_LABEL, label or f"Layer {len(self.layers())}")
|
|
956
|
+
elif label:
|
|
957
|
+
g.set(INK_LABEL, label)
|
|
958
|
+
g.set("id", self._auto("layer" if layer else "g", self.ids()))
|
|
959
|
+
for e in els:
|
|
960
|
+
self._move(e, g, "top", None)
|
|
961
|
+
if layer:
|
|
962
|
+
self.current_layer = g.get("id")
|
|
963
|
+
return f"grouped {len(els)} into {desc(g)} in {desc(dest)}: {', '.join(desc(e) for e in els)}", [g.get("id")]
|
|
964
|
+
|
|
965
|
+
@mutation
|
|
966
|
+
def ungroup(self, ids):
|
|
967
|
+
out_ids, names = [], []
|
|
968
|
+
for g in self.targets(ids):
|
|
969
|
+
if local(g) not in ("g", "a"):
|
|
970
|
+
raise EditError(f"{desc(g)} is not a group")
|
|
971
|
+
self.check_unlocked(g)
|
|
972
|
+
for p in ("mask", "clip-path", "filter"):
|
|
973
|
+
if get_prop(g, p) not in (None, "none"):
|
|
974
|
+
raise EditError(f"{desc(g)} has a {p}; unmask/remove it before ungrouping")
|
|
975
|
+
gtf = g.get("transform") or ""
|
|
976
|
+
inherit = {p: get_prop(g, p) for p in INHERITED if get_prop(g, p) is not None}
|
|
977
|
+
if get_prop(g, "display") == "none":
|
|
978
|
+
inherit["display"] = "none"
|
|
979
|
+
try:
|
|
980
|
+
gop = float(get_prop(g, "opacity") or 1)
|
|
981
|
+
except ValueError:
|
|
982
|
+
gop = 1.0
|
|
983
|
+
for k in list(g):
|
|
984
|
+
g.addprevious(k)
|
|
985
|
+
if not (is_el(k) and is_svg(k)) or local(k) in NON_DRAWABLE:
|
|
986
|
+
continue
|
|
987
|
+
if gtf:
|
|
988
|
+
prepend_transform(k, gtf)
|
|
989
|
+
for p, v in inherit.items():
|
|
990
|
+
if get_prop(k, p) is None:
|
|
991
|
+
k.set(p, v)
|
|
992
|
+
if gop != 1:
|
|
993
|
+
st = parse_style(k.get("style"))
|
|
994
|
+
try:
|
|
995
|
+
ko = float(get_prop(k, "opacity") or 1) * gop
|
|
996
|
+
except ValueError:
|
|
997
|
+
ko = gop
|
|
998
|
+
if "opacity" in st:
|
|
999
|
+
st["opacity"] = fmt(ko)
|
|
1000
|
+
k.set("style", style_str(st))
|
|
1001
|
+
else:
|
|
1002
|
+
k.set("opacity", fmt(ko))
|
|
1003
|
+
out_ids.append(k.get("id"))
|
|
1004
|
+
names.append(desc(g))
|
|
1005
|
+
if self.current_layer == g.get("id"):
|
|
1006
|
+
self.current_layer = None
|
|
1007
|
+
g.getparent().remove(g)
|
|
1008
|
+
return f"ungrouped {', '.join(names)} → {len(out_ids)} elements", out_ids
|
|
1009
|
+
|
|
1010
|
+
def _clip_flatten(self, els) -> list:
|
|
1011
|
+
out = []
|
|
1012
|
+
for e in els:
|
|
1013
|
+
if local(e) in ("g", "a"):
|
|
1014
|
+
tf = e.get("transform") or ""
|
|
1015
|
+
for c in children(e):
|
|
1016
|
+
for f in self._clip_flatten([c]):
|
|
1017
|
+
if tf:
|
|
1018
|
+
prepend_transform(f, tf)
|
|
1019
|
+
out.append(f)
|
|
1020
|
+
elif local(e) in CLIP_SHAPES:
|
|
1021
|
+
out.append(e)
|
|
1022
|
+
elif local(e) not in ("title", "desc"):
|
|
1023
|
+
raise EditError(f"<{local(e)}> can't be part of a clip; use shapes, text or <use> (or kind='mask')")
|
|
1024
|
+
return out
|
|
1025
|
+
|
|
1026
|
+
@mutation
|
|
1027
|
+
def mask(self, targets, source, kind="mask", mode="luminance"):
|
|
1028
|
+
if kind not in ("mask", "clip"):
|
|
1029
|
+
raise EditError("kind must be 'mask' or 'clip'")
|
|
1030
|
+
if mode not in ("luminance", "alpha"):
|
|
1031
|
+
raise EditError("mode must be 'luminance' or 'alpha'")
|
|
1032
|
+
attr = "mask" if kind == "mask" else "clip-path"
|
|
1033
|
+
tg = self.targets(targets)
|
|
1034
|
+
for t in tg:
|
|
1035
|
+
self.check_unlocked(t)
|
|
1036
|
+
if get_prop(t, attr) not in (None, "none"):
|
|
1037
|
+
raise EditError(f"{desc(t)} already has a {attr}; unmask it first")
|
|
1038
|
+
notes = []
|
|
1039
|
+
src = source.strip()
|
|
1040
|
+
if src.startswith("<"):
|
|
1041
|
+
src_els = parse_fragment(src)
|
|
1042
|
+
notes = sanitize(src_els)
|
|
1043
|
+
space = {id(t): parent_ctm(t) for t in tg} # markup is drawn in each target's parent space
|
|
1044
|
+
src_name = ", ".join(f"<{local(e)}>" for e in src_els)
|
|
1045
|
+
else:
|
|
1046
|
+
s = self.get(src)
|
|
1047
|
+
if s in tg or any(a in tg for a in s.iterancestors()) or any(t in tg for t in iter_els(s)):
|
|
1048
|
+
raise EditError("the mask source must not be (inside) a target")
|
|
1049
|
+
if s is self.root or local(s) in NON_DRAWABLE:
|
|
1050
|
+
raise EditError(f"{desc(s)} can't be a mask source")
|
|
1051
|
+
self.check_unlocked(s)
|
|
1052
|
+
sp = parent_ctm(s)
|
|
1053
|
+
space = {id(t): sp for t in tg}
|
|
1054
|
+
src_name = desc(s)
|
|
1055
|
+
s.getparent().remove(s)
|
|
1056
|
+
s.tail = None
|
|
1057
|
+
src_els = [s]
|
|
1058
|
+
groups: list[tuple] = []
|
|
1059
|
+
for t in tg:
|
|
1060
|
+
m = mul(inv(el_ctm(t)), space[id(t)])
|
|
1061
|
+
for g in groups:
|
|
1062
|
+
if same(g[0], m):
|
|
1063
|
+
g[1].append(t)
|
|
1064
|
+
break
|
|
1065
|
+
else:
|
|
1066
|
+
groups.append((m, [t]))
|
|
1067
|
+
made = []
|
|
1068
|
+
defs = self.defs()
|
|
1069
|
+
for i, (m, ts) in enumerate(groups):
|
|
1070
|
+
content = src_els if i == 0 else [copy.deepcopy(e) for e in src_els]
|
|
1071
|
+
if kind == "clip":
|
|
1072
|
+
content = self._clip_flatten(content)
|
|
1073
|
+
cont = etree.SubElement(defs, q("mask" if kind == "mask" else "clipPath"))
|
|
1074
|
+
for c in content:
|
|
1075
|
+
cont.append(c)
|
|
1076
|
+
if not is_ident(m):
|
|
1077
|
+
prepend_transform(c, nice_matrix(m))
|
|
1078
|
+
if kind == "mask" and mode == "alpha":
|
|
1079
|
+
cont.set("mask-type", "alpha")
|
|
1080
|
+
cont.set("style", "mask-type:alpha")
|
|
1081
|
+
self.assign_ids([cont])
|
|
1082
|
+
for t in ts:
|
|
1083
|
+
t.set(attr, f"url(#{cont.get('id')})")
|
|
1084
|
+
made.append(cont)
|
|
1085
|
+
what = f"{kind}" + (f" ({mode})" if kind == "mask" else "")
|
|
1086
|
+
out = [
|
|
1087
|
+
f"{what} from {src_name} applied to {', '.join(desc(t) for t in tg)} via "
|
|
1088
|
+
+ ", ".join(desc(c) for c in made)
|
|
1089
|
+
] + notes
|
|
1090
|
+
return "\n".join(out), [t.get("id") for t in tg]
|
|
1091
|
+
|
|
1092
|
+
@mutation
|
|
1093
|
+
def unmask(self, targets):
|
|
1094
|
+
out_ids, lines = [], []
|
|
1095
|
+
for t in self.targets(targets):
|
|
1096
|
+
self.check_unlocked(t)
|
|
1097
|
+
found = False
|
|
1098
|
+
for attr in ("mask", "clip-path"):
|
|
1099
|
+
v = get_prop(t, attr)
|
|
1100
|
+
if not v or v == "none":
|
|
1101
|
+
continue
|
|
1102
|
+
found = True
|
|
1103
|
+
t.attrib.pop(attr, None)
|
|
1104
|
+
st = parse_style(t.get("style"))
|
|
1105
|
+
if attr in st:
|
|
1106
|
+
st.pop(attr)
|
|
1107
|
+
if st:
|
|
1108
|
+
t.set("style", style_str(st))
|
|
1109
|
+
else:
|
|
1110
|
+
t.attrib.pop("style", None)
|
|
1111
|
+
m = URL_REF.search(v)
|
|
1112
|
+
cont = self.find(m.group(1)) if m else None
|
|
1113
|
+
if cont is None:
|
|
1114
|
+
lines.append(f"{desc(t)}: {attr} pointed at a missing element; attribute removed")
|
|
1115
|
+
continue
|
|
1116
|
+
cid = cont.get("id")
|
|
1117
|
+
shared = any(e is not t and cid in refs_of(e) for e in iter_els(self.root))
|
|
1118
|
+
content = [copy.deepcopy(c) for c in children(cont)] if shared else children(cont)
|
|
1119
|
+
if not shared:
|
|
1120
|
+
cont.getparent().remove(cont)
|
|
1121
|
+
for c in content:
|
|
1122
|
+
c.tail = None
|
|
1123
|
+
if shared:
|
|
1124
|
+
self.assign_ids(content)
|
|
1125
|
+
anchor, tf = t, t.get("transform") or ""
|
|
1126
|
+
for c in content:
|
|
1127
|
+
if tf:
|
|
1128
|
+
prepend_transform(c, tf)
|
|
1129
|
+
anchor.addnext(c)
|
|
1130
|
+
anchor = c
|
|
1131
|
+
out_ids.append(c.get("id"))
|
|
1132
|
+
lines.append(
|
|
1133
|
+
f"released {attr} of {desc(t)}: {', '.join(desc(c) for c in content)} placed above it"
|
|
1134
|
+
+ (" (mask still used elsewhere, content copied)" if shared else "")
|
|
1135
|
+
)
|
|
1136
|
+
if not found:
|
|
1137
|
+
raise EditError(f"{desc(t)} has no mask or clip-path")
|
|
1138
|
+
return "\n".join(lines), out_ids + [i for i in (targets if isinstance(targets, list) else [targets])]
|
|
1139
|
+
|
|
1140
|
+
@mutation
|
|
1141
|
+
def set_props(self, name=None, width=None, height=None, units=None, view_box=None, background=None, fit=None):
|
|
1142
|
+
r = self.root
|
|
1143
|
+
out = []
|
|
1144
|
+
if name:
|
|
1145
|
+
self.name = name
|
|
1146
|
+
out.append(f'name "{name}"')
|
|
1147
|
+
cw, ch = parse_length(r.get("width")) or (None, ""), parse_length(r.get("height")) or (None, "")
|
|
1148
|
+
unit = units if units is not None else (cw[1] or "px")
|
|
1149
|
+
if unit not in UNITS:
|
|
1150
|
+
raise EditError(f"units must be one of {', '.join(UNITS)}")
|
|
1151
|
+
suffix = "" if unit == "px" else unit
|
|
1152
|
+
if width is not None or height is not None or units is not None:
|
|
1153
|
+
vb = R.viewbox(r)
|
|
1154
|
+
ow, oh = cw[0] or vb[2], ch[0] or vb[3]
|
|
1155
|
+
nw = float(width) if width is not None else ow
|
|
1156
|
+
nh = float(height) if height is not None else oh
|
|
1157
|
+
if nw <= 0 or nh <= 0:
|
|
1158
|
+
raise EditError("width/height must be > 0")
|
|
1159
|
+
if view_box is None and r.get("viewBox") and (width is not None or height is not None):
|
|
1160
|
+
r.set("viewBox", " ".join(fmt(v) for v in (vb[0], vb[1], vb[2] * nw / ow, vb[3] * nh / oh)))
|
|
1161
|
+
r.set("width", fmt(nw) + suffix)
|
|
1162
|
+
r.set("height", fmt(nh) + suffix)
|
|
1163
|
+
self.namedview().set(f"{{{INK_NS}}}document-units", unit)
|
|
1164
|
+
out.append(f"size {r.get('width')}×{r.get('height')}")
|
|
1165
|
+
if view_box is not None:
|
|
1166
|
+
nums = [float(x) for x in re.split(r"[\s,]+", str(view_box).strip()) if x]
|
|
1167
|
+
if len(nums) != 4 or nums[2] <= 0 or nums[3] <= 0:
|
|
1168
|
+
raise EditError("view_box must be 'minx miny width height' with width, height > 0")
|
|
1169
|
+
r.set("viewBox", " ".join(fmt(v) for v in nums))
|
|
1170
|
+
out.append(f"viewBox {r.get('viewBox')}")
|
|
1171
|
+
if background is not None:
|
|
1172
|
+
bg = background.strip()
|
|
1173
|
+
self.set_background(None if bg.lower() in ("", "none", "transparent") else bg)
|
|
1174
|
+
out.append(f"background {self.background or 'none'}")
|
|
1175
|
+
if fit is not None:
|
|
1176
|
+
if fit != "content":
|
|
1177
|
+
raise EditError("fit must be 'content'")
|
|
1178
|
+
box = R.measure(r)
|
|
1179
|
+
if not box:
|
|
1180
|
+
raise EditError("drawing is empty; nothing to fit")
|
|
1181
|
+
vb = R.viewbox(r)
|
|
1182
|
+
lw = parse_length(r.get("width")) or (vb[2], "")
|
|
1183
|
+
lh = parse_length(r.get("height")) or (vb[3], "")
|
|
1184
|
+
sx, sy = lw[0] / vb[2], lh[0] / vb[3]
|
|
1185
|
+
r.set("viewBox", " ".join(fmt(v) for v in box))
|
|
1186
|
+
r.set("width", fmt(box[2] * sx) + lw[1])
|
|
1187
|
+
r.set("height", fmt(box[3] * sy) + lh[1])
|
|
1188
|
+
out.append(f"fit to content: viewBox {r.get('viewBox')}, size {r.get('width')}×{r.get('height')}")
|
|
1189
|
+
if not out:
|
|
1190
|
+
raise EditError("nothing to set")
|
|
1191
|
+
return "set " + "; ".join(out), []
|
|
1192
|
+
|
|
1193
|
+
def undo(self, steps=1) -> str:
|
|
1194
|
+
with self.lock:
|
|
1195
|
+
if not self.history:
|
|
1196
|
+
raise EditError("nothing to undo")
|
|
1197
|
+
n = max(1, min(int(steps), len(self.history)))
|
|
1198
|
+
snap = None
|
|
1199
|
+
for _ in range(n):
|
|
1200
|
+
snap = self.history.pop()
|
|
1201
|
+
self._restore(snap)
|
|
1202
|
+
self.version += 1
|
|
1203
|
+
self.dirty = True
|
|
1204
|
+
self.touched = []
|
|
1205
|
+
summary = f"undid {n} step(s); {len(self.history)} left"
|
|
1206
|
+
if self.on_change:
|
|
1207
|
+
self.on_change(self, summary)
|
|
1208
|
+
return summary
|
|
1209
|
+
|
|
1210
|
+
# ── export ──────────────────────────────────────────────────────────────
|
|
1211
|
+
def export(self, path=None, format=None, scale=1.0, area="page", plain=False, background=True) -> str:
|
|
1212
|
+
with self.lock:
|
|
1213
|
+
target = path or self.path
|
|
1214
|
+
if not target:
|
|
1215
|
+
raise EditError("no path: pass path= (this document was never saved)")
|
|
1216
|
+
P = resolve_path(target)
|
|
1217
|
+
kind = (format or P.suffix.lstrip(".")).lower()
|
|
1218
|
+
if kind not in ("svg", "png"):
|
|
1219
|
+
raise EditError("format must be 'svg' or 'png' (or use a .svg / .png path)")
|
|
1220
|
+
root = copy.deepcopy(self.root)
|
|
1221
|
+
bg = self.background if background else None
|
|
1222
|
+
if area != "page":
|
|
1223
|
+
ids = None if area == "drawing" else ([area] if isinstance(area, str) else list(area))
|
|
1224
|
+
if ids:
|
|
1225
|
+
ids = {e.get("id") for e in self.targets(ids)}
|
|
1226
|
+
src = R.isolate(root, ids) if ids else root
|
|
1227
|
+
box = R.measure(src)
|
|
1228
|
+
if not box:
|
|
1229
|
+
raise EditError("nothing visible to export in that area")
|
|
1230
|
+
vb = R.viewbox(root)
|
|
1231
|
+
lw = parse_length(root.get("width")) or (vb[2], "")
|
|
1232
|
+
lh = parse_length(root.get("height")) or (vb[3], "")
|
|
1233
|
+
root = src
|
|
1234
|
+
root.set("viewBox", " ".join(fmt(v) for v in box))
|
|
1235
|
+
root.set("width", fmt(box[2] * lw[0] / vb[2]) + lw[1])
|
|
1236
|
+
root.set("height", fmt(box[3] * lh[0] / vb[3]) + lh[1])
|
|
1237
|
+
P.parent.mkdir(parents=True, exist_ok=True)
|
|
1238
|
+
existed = P.exists()
|
|
1239
|
+
if kind == "png":
|
|
1240
|
+
resdir = str(Path(self.path).parent) if self.path else None
|
|
1241
|
+
png, w, h, s = R.render_page(root, scale, bg, resdir)
|
|
1242
|
+
P.write_bytes(png)
|
|
1243
|
+
dims = f"{w}×{h}px"
|
|
1244
|
+
else:
|
|
1245
|
+
if bg:
|
|
1246
|
+
vb = R.viewbox(root)
|
|
1247
|
+
rect = etree.Element(q("rect"))
|
|
1248
|
+
for k, v in zip(("x", "y", "width", "height"), vb):
|
|
1249
|
+
rect.set(k, fmt(v))
|
|
1250
|
+
rect.set("fill", bg)
|
|
1251
|
+
rect.set("id", "background")
|
|
1252
|
+
root.insert(self._drawable_start(root), rect)
|
|
1253
|
+
if plain:
|
|
1254
|
+
for e in list(iter_els(root)):
|
|
1255
|
+
ns = etree.QName(e).namespace
|
|
1256
|
+
if ns in (INK_NS, SODI_NS) and e.getparent() is not None:
|
|
1257
|
+
e.getparent().remove(e)
|
|
1258
|
+
continue
|
|
1259
|
+
for k in list(e.attrib):
|
|
1260
|
+
if etree.QName(k).namespace in (INK_NS, SODI_NS):
|
|
1261
|
+
del e.attrib[k]
|
|
1262
|
+
etree.cleanup_namespaces(root)
|
|
1263
|
+
pretty(root)
|
|
1264
|
+
data = b'<?xml version="1.0" encoding="UTF-8"?>\n' + etree.tostring(root, encoding="utf-8")
|
|
1265
|
+
P.write_bytes(data)
|
|
1266
|
+
dims = f"{root.get('width')}×{root.get('height')}"
|
|
1267
|
+
if area == "page" and not plain:
|
|
1268
|
+
self.path = str(P)
|
|
1269
|
+
self.dirty = False
|
|
1270
|
+
return f"{'overwrote' if existed else 'wrote'} {P} ({kind}, {dims}, {P.stat().st_size / 1024:.1f}kB)"
|
|
1271
|
+
|
|
1272
|
+
|
|
1273
|
+
class Store:
|
|
1274
|
+
"""All open documents. One lock serialises edits against preview reads."""
|
|
1275
|
+
|
|
1276
|
+
def __init__(self):
|
|
1277
|
+
self.docs: dict[str, Document] = {}
|
|
1278
|
+
self.lock = threading.RLock()
|
|
1279
|
+
self.on_change = None # (doc, summary) -> None
|
|
1280
|
+
self.on_close = None # (doc_id) -> None
|
|
1281
|
+
|
|
1282
|
+
def get(self, doc_id: str) -> Document:
|
|
1283
|
+
d = self.docs.get(str(doc_id).strip())
|
|
1284
|
+
if d:
|
|
1285
|
+
return d
|
|
1286
|
+
if not self.docs:
|
|
1287
|
+
raise EditError(f"no document '{doc_id}'; none are open (doc_create)")
|
|
1288
|
+
raise EditError(f"no document '{doc_id}'; open: {', '.join(self.docs)}")
|
|
1289
|
+
|
|
1290
|
+
def create(self, name=None, width=None, height=None, units="px", view_box=None,
|
|
1291
|
+
background=None, layers=None, source=None) -> tuple[Document, list[str]]:
|
|
1292
|
+
notes: list[str] = []
|
|
1293
|
+
path = None
|
|
1294
|
+
if source and source.strip():
|
|
1295
|
+
s = source.strip()
|
|
1296
|
+
if s.startswith("<"):
|
|
1297
|
+
root = parse_document(s)
|
|
1298
|
+
else:
|
|
1299
|
+
P = resolve_path(s)
|
|
1300
|
+
if not P.exists():
|
|
1301
|
+
raise EditError(f"file not found: {P}")
|
|
1302
|
+
raw = P.read_bytes()
|
|
1303
|
+
if P.suffix.lower() == ".svgz" or raw[:2] == b"\x1f\x8b":
|
|
1304
|
+
raw = gzip.decompress(raw)
|
|
1305
|
+
root = parse_document(raw.decode("utf-8-sig"))
|
|
1306
|
+
path = str(P)
|
|
1307
|
+
name = name or P.stem
|
|
1308
|
+
notes += sanitize([root])
|
|
1309
|
+
else:
|
|
1310
|
+
root = new_root()
|
|
1311
|
+
if units not in UNITS:
|
|
1312
|
+
raise EditError(f"units must be one of {', '.join(UNITS)}")
|
|
1313
|
+
name = (name or "untitled").strip()
|
|
1314
|
+
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:24] or "doc"
|
|
1315
|
+
doc_id = f"{slug}__{uuid.uuid4().hex[:6]}"
|
|
1316
|
+
doc = Document(doc_id, name, root, self.lock, self._changed, path)
|
|
1317
|
+
suffix = "" if units == "px" else units
|
|
1318
|
+
imported = source is not None and bool(source.strip())
|
|
1319
|
+
if not imported or width is not None or height is not None:
|
|
1320
|
+
w = float(width if width is not None else (800 if not imported else R.size_px(root)[0]))
|
|
1321
|
+
h = float(height if height is not None else (600 if not imported else R.size_px(root)[1]))
|
|
1322
|
+
if w <= 0 or h <= 0:
|
|
1323
|
+
raise EditError("width/height must be > 0")
|
|
1324
|
+
root.set("width", fmt(w) + suffix)
|
|
1325
|
+
root.set("height", fmt(h) + suffix)
|
|
1326
|
+
if not root.get("viewBox"):
|
|
1327
|
+
root.set("viewBox", f"0 0 {fmt(w)} {fmt(h)}")
|
|
1328
|
+
if view_box:
|
|
1329
|
+
nums = [float(x) for x in re.split(r"[\s,]+", view_box.strip()) if x]
|
|
1330
|
+
if len(nums) != 4 or nums[2] <= 0 or nums[3] <= 0:
|
|
1331
|
+
raise EditError("view_box must be 'minx miny width height' with width, height > 0")
|
|
1332
|
+
root.set("viewBox", " ".join(fmt(v) for v in nums))
|
|
1333
|
+
nv = doc.namedview()
|
|
1334
|
+
nv.set(f"{{{INK_NS}}}document-units", units if not imported else (nv.get(f"{{{INK_NS}}}document-units") or units))
|
|
1335
|
+
if background is not None:
|
|
1336
|
+
bg = background.strip()
|
|
1337
|
+
doc.set_background(None if bg.lower() in ("", "none", "transparent") else bg)
|
|
1338
|
+
elif imported:
|
|
1339
|
+
try:
|
|
1340
|
+
if float(nv.get(f"{{{INK_NS}}}pageopacity") or 0) > 0 and nv.get("pagecolor"):
|
|
1341
|
+
doc.background = nv.get("pagecolor")
|
|
1342
|
+
except ValueError:
|
|
1343
|
+
pass
|
|
1344
|
+
doc.assign_ids([root])
|
|
1345
|
+
if imported:
|
|
1346
|
+
wrapped = doc.normalise_layers()
|
|
1347
|
+
if wrapped:
|
|
1348
|
+
notes.append(f'wrapped {wrapped} loose elements into layer "Layer 1"')
|
|
1349
|
+
existing = {l.get(INK_LABEL) for l in doc.layers()}
|
|
1350
|
+
wanted = list(layers) if layers else ([] if imported else ["Layer 1"])
|
|
1351
|
+
for label in wanted:
|
|
1352
|
+
if label not in existing:
|
|
1353
|
+
doc._new_layer(label, root)
|
|
1354
|
+
lays = [c for c in children(root) if is_layer(c)]
|
|
1355
|
+
doc.current_layer = lays[-1].get("id") if lays else None
|
|
1356
|
+
with self.lock:
|
|
1357
|
+
self.docs[doc_id] = doc
|
|
1358
|
+
self._changed(doc, f"opened {doc.name}")
|
|
1359
|
+
return doc, notes
|
|
1360
|
+
|
|
1361
|
+
def close(self, doc_id: str, discard: bool = False) -> str:
|
|
1362
|
+
with self.lock:
|
|
1363
|
+
d = self.get(doc_id)
|
|
1364
|
+
if d.dirty and not discard:
|
|
1365
|
+
raise EditError(
|
|
1366
|
+
f"{d.id} has unsaved changes (v{d.version}); doc_export it first or pass discard=true"
|
|
1367
|
+
)
|
|
1368
|
+
del self.docs[d.id]
|
|
1369
|
+
if self.on_close:
|
|
1370
|
+
self.on_close(d.id)
|
|
1371
|
+
return f"closed {d.id}" + (" (unsaved changes discarded)" if d.dirty else "")
|
|
1372
|
+
|
|
1373
|
+
def _changed(self, doc, summary):
|
|
1374
|
+
if self.on_change:
|
|
1375
|
+
self.on_change(doc, summary)
|