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/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)