返回 ppt-master
extract_svg_pictures.py
根目录 / skills / ppt-master / scripts / extract_svg_pictures.py
1 #!/usr/bin/env python3
2 """
3 PPT Master - Explicit SVG Picture Asset Extractor
4
5 Extract explicitly selected ``<g id>`` elements from one SVG into tight,
6 self-contained SVG picture assets. Each selected group is replaced in place by
7 one ``<image>`` at the same parent index, so native export produces one
8 PowerPoint ``p:pic`` instead of a DrawingML group.
9
10 This tool is intentionally selection-only. It does not discover repeated
11 objects, infer Master/Layout structure, or change the native-shape semantics of
12 ``extract_svg_assets.py``.
13
14 Usage:
15 python3 scripts/extract_svg_pictures.py page.svg --select emblem \
16 --images-dir project/images -o project/svg_output/page.svg
17
18 Examples:
19 python3 scripts/extract_svg_pictures.py imported/slide_01.svg \
20 --select shape-15 --resource-root imported \
21 --images-dir imported/images --inplace
22 python3 scripts/extract_svg_pictures.py source.svg --select complex-art \
23 --bounds complex-art=80,120,320,240 --images-dir work/images \
24 -o work/normalized.svg
25
26 Dependencies:
27 Standard library. Playwright is optional for targets without an explicit
28 ``--bounds`` value or imported ``data-pptx-frame`` metadata.
29
30 See workflows/create-template.md and scripts/docs/svg-pipeline.md.
31 """
32
33 from __future__ import annotations
34
35 import argparse
36 import base64
37 import copy
38 import hashlib
39 import json
40 import math
41 import mimetypes
42 import os
43 import re
44 import sys
45 from dataclasses import dataclass
46 from pathlib import Path
47 from urllib.parse import unquote, urlsplit, urlunsplit
48 from xml.etree import ElementTree as ET
49
50 from console_encoding import configure_utf8_stdio
51 from svg_authoring_view import (
52 AUTHORING_MANIFEST_NAME,
53 write_authoring_summary,
54 )
55
56 configure_utf8_stdio()
57
58 SVG_NS = "http://www.w3.org/2000/svg"
59 XLINK_NS = "http://www.w3.org/1999/xlink"
60 GEOMETRY_ROOT_ATTRS = {"viewBox", "width", "height", "x", "y", "transform"}
61 PRESERVED_IMAGE_ATTRS = {
62 "aria-label",
63 "data-name",
64 "data-pptx-layer",
65 "data-pptx-role",
66 "data-pptx-shape-name",
67 "id",
68 }
69 SEMANTIC_MARKERS = {
70 "data-icon",
71 "data-pptx-authoring",
72 "data-pptx-import-source",
73 "data-pptx-placeholder",
74 "data-pptx-replace-with",
75 }
76 SEMANTIC_PREFIXES = (
77 "data-pptx-fallback-",
78 "data-pptx-native",
79 "data-pptx-placeholder-",
80 "data-pptx-replace",
81 )
82 VISUAL_TAGS = {
83 "circle",
84 "ellipse",
85 "image",
86 "line",
87 "path",
88 "polygon",
89 "polyline",
90 "rect",
91 "svg",
92 "text",
93 "use",
94 }
95 UNSAFE_PICTURE_TAGS = {
96 "animate",
97 "animateMotion",
98 "animateTransform",
99 "foreignObject",
100 "script",
101 "set",
102 }
103 _URL_RE = re.compile(r"url\(\s*(['\"]?)([^)'\"]+)\1\s*\)", re.IGNORECASE)
104 _FRAME_SPLIT_RE = re.compile(r"[\s,]+")
105
106 ET.register_namespace("", SVG_NS)
107 ET.register_namespace("xlink", XLINK_NS)
108
109
110 class SvgPictureError(RuntimeError):
111 """Raised when an explicit picture extraction cannot be completed safely."""
112
113
114 @dataclass(frozen=True)
115 class Bounds:
116 """One positive rectangle in source-root user coordinates."""
117
118 x: float
119 y: float
120 width: float
121 height: float
122
123 @dataclass
124 class ExtractionPlan:
125 """Prepared output for one selected source group."""
126
127 selector: str
128 asset_path: Path
129 href: str
130 bounds: Bounds
131 bounds_source: str
132 asset_bytes: bytes
133 dependency_ids: list[str]
134 embedded_resources: list[str]
135
136
137 def _local(tag: object) -> str:
138 return tag.rsplit("}", 1)[-1] if isinstance(tag, str) else ""
139
140
141 def _fmt(value: float) -> str:
142 rounded = round(value, 6)
143 if rounded == 0:
144 rounded = 0.0
145 return f"{rounded:.6f}".rstrip("0").rstrip(".")
146
147
148 def _padded_bounds(bounds: Bounds, padding: float) -> Bounds:
149 return Bounds(
150 bounds.x - padding,
151 bounds.y - padding,
152 bounds.width + 2 * padding,
153 bounds.height + 2 * padding,
154 )
155
156
157 def _parse_svg(path: Path) -> tuple[ET.ElementTree, ET.Element]:
158 parser = ET.XMLParser(target=ET.TreeBuilder(insert_comments=True))
159 try:
160 tree = ET.parse(path, parser=parser)
161 except (ET.ParseError, OSError) as exc:
162 raise SvgPictureError(f"Cannot parse SVG {path}: {exc}") from exc
163 root = tree.getroot()
164 if _local(root.tag) != "svg":
165 raise SvgPictureError(f"Expected an SVG root in {path}")
166 return tree, root
167
168
169 def _parent_map(root: ET.Element) -> dict[ET.Element, ET.Element]:
170 return {child: parent for parent in root.iter() for child in parent}
171
172
173 def _index_ids(root: ET.Element) -> dict[str, ET.Element]:
174 by_id: dict[str, ET.Element] = {}
175 duplicates: list[str] = []
176 for elem in root.iter():
177 elem_id = (elem.get("id") or "").strip()
178 if not elem_id:
179 continue
180 if elem_id in by_id:
181 duplicates.append(elem_id)
182 by_id[elem_id] = elem
183 if duplicates:
184 raise SvgPictureError("Duplicate SVG id(s): " + ", ".join(sorted(set(duplicates))))
185 return by_id
186
187
188 def _is_descendant(container: ET.Element, candidate: ET.Element) -> bool:
189 return any(elem is candidate for elem in container.iter())
190
191
192 def _validate_source(root: ET.Element) -> None:
193 if root.get("transform"):
194 raise SvgPictureError("Root-level SVG transform is unsupported; normalize it before extraction")
195 if any(_local(elem.tag) == "script" for elem in root.iter()):
196 raise SvgPictureError("SVG scripts are not allowed in extracted picture assets")
197 for elem in root.iter():
198 if _local(elem.tag) == "style" and "@import" in (elem.text or "").lower():
199 raise SvgPictureError("CSS @import is not allowed in extracted picture assets")
200
201
202 def _validate_resource_value(value: str, *, allow_fragment: bool) -> None:
203 raw = value.strip()
204 if not raw or raw.startswith("data:"):
205 return
206 if allow_fragment and raw.startswith("#"):
207 return
208 parsed = urlsplit(raw)
209 if parsed.scheme or parsed.netloc:
210 raise SvgPictureError(f"Remote or scheme-based SVG resource is not allowed: {raw}")
211 if parsed.fragment and parsed.path.lower().endswith(".svg"):
212 raise SvgPictureError(f"External SVG fragment references are not supported: {raw}")
213
214
215 def _validate_target(selector: str, target: ET.Element) -> None:
216 if _local(target.tag) != "g":
217 raise SvgPictureError(f"Selector {selector!r} must identify one <g> element")
218 object_kind = (target.get("data-pptx-object") or "").strip()
219 if object_kind and object_kind != "group":
220 raise SvgPictureError(
221 f"Selector {selector!r} is one imported {object_kind!r} object, not a complex group"
222 )
223 if not any(_local(elem.tag) in VISUAL_TAGS for elem in target.iter()):
224 raise SvgPictureError(f"Selector {selector!r} has no visual SVG content")
225 for elem in target.iter():
226 if _local(elem.tag) in UNSAFE_PICTURE_TAGS:
227 raise SvgPictureError(
228 f"Selector {selector!r} contains unsupported <{_local(elem.tag)}> content"
229 )
230 markers = _semantic_markers(elem)
231 if markers:
232 raise SvgPictureError(
233 f"Selector {selector!r} contains {markers[0]}; preserve its existing semantic route"
234 )
235
236
237 def _semantic_markers(elem: ET.Element) -> list[str]:
238 return sorted(
239 _local(name)
240 for name in elem.attrib
241 if _local(name) in SEMANTIC_MARKERS
242 or _local(name).startswith(SEMANTIC_PREFIXES)
243 )
244
245
246 def _parse_bounds(raw: str, context: str) -> Bounds:
247 tokens = [token for token in _FRAME_SPLIT_RE.split(raw.strip()) if token]
248 if len(tokens) != 4:
249 raise SvgPictureError(f"{context} must contain x,y,width,height")
250 try:
251 values = [float(token) for token in tokens]
252 except ValueError as exc:
253 raise SvgPictureError(f"{context} contains a non-numeric coordinate") from exc
254 if not all(math.isfinite(value) for value in values):
255 raise SvgPictureError(f"{context} must contain finite coordinates")
256 bounds = Bounds(*values)
257 if bounds.width <= 0 or bounds.height <= 0:
258 raise SvgPictureError(f"{context} width and height must be positive")
259 return bounds
260
261
262 def _parse_key_values(values: list[str], option: str) -> dict[str, str]:
263 parsed: dict[str, str] = {}
264 for raw in values:
265 key, separator, value = raw.partition("=")
266 key = key.strip()
267 if not separator or not key or not value.strip():
268 raise SvgPictureError(f"{option} expects ID=value, got {raw!r}")
269 if key in parsed:
270 raise SvgPictureError(f"{option} repeats selector {key!r}")
271 parsed[key] = value.strip()
272 return parsed
273
274
275 def _measure_bounds(source: Path, selectors: list[str]) -> dict[str, Bounds]:
276 try:
277 from playwright.sync_api import Error as PlaywrightError
278 from playwright.sync_api import sync_playwright
279 except ImportError as exc:
280 raise SvgPictureError(
281 "Playwright is required to measure this target; install it or pass --bounds ID=x,y,w,h"
282 ) from exc
283
284 script = """
285 (ids) => Object.fromEntries(ids.map((id) => {
286 const element = document.getElementById(id);
287 const root = element && element.ownerSVGElement;
288 const rootMatrix = root && root.getScreenCTM();
289 if (!element || !root || !rootMatrix) throw new Error(`Cannot measure ${id}`);
290 const rect = element.getBoundingClientRect();
291 const inverse = rootMatrix.inverse();
292 const points = [
293 new DOMPoint(rect.left, rect.top), new DOMPoint(rect.right, rect.top),
294 new DOMPoint(rect.right, rect.bottom), new DOMPoint(rect.left, rect.bottom),
295 ].map((point) => point.matrixTransform(inverse));
296 const xs = points.map((point) => point.x);
297 const ys = points.map((point) => point.y);
298 return [id, {
299 x: Math.min(...xs), y: Math.min(...ys),
300 width: Math.max(...xs) - Math.min(...xs),
301 height: Math.max(...ys) - Math.min(...ys),
302 }];
303 }))
304 """
305 try:
306 with sync_playwright() as playwright:
307 browser = playwright.chromium.launch(headless=True)
308 page = browser.new_page(viewport={"width": 1920, "height": 1080})
309 page.route("http://**/*", lambda route: route.abort())
310 page.route("https://**/*", lambda route: route.abort())
311 page.goto(source.resolve().as_uri(), wait_until="load")
312 page.evaluate("document.fonts ? document.fonts.ready : Promise.resolve()")
313 measured = page.evaluate(script, selectors)
314 browser.close()
315 except (PlaywrightError, OSError, RuntimeError) as exc:
316 raise SvgPictureError(
317 "Browser measurement failed; pass explicit --bounds ID=x,y,w,h if needed: "
318 f"{exc}"
319 ) from exc
320
321 return {
322 selector: _parse_bounds(
323 ",".join(str(measured[selector][field]) for field in ("x", "y", "width", "height")),
324 f"measured bounds for {selector}",
325 )
326 for selector in selectors
327 }
328
329
330 def _resolve_bounds(
331 source: Path,
332 targets: dict[str, ET.Element],
333 overrides: dict[str, str],
334 mode: str,
335 padding: float,
336 ) -> dict[str, tuple[Bounds, str]]:
337 resolved: dict[str, tuple[Bounds, str]] = {}
338 measure: list[str] = []
339 for selector, target in targets.items():
340 if selector in overrides:
341 resolved[selector] = (_parse_bounds(overrides[selector], f"--bounds {selector}"), "explicit")
342 continue
343 frame = target.get("data-pptx-frame")
344 if mode != "measure" and frame:
345 resolved[selector] = (_parse_bounds(frame, f"{selector} data-pptx-frame"), "data-pptx-frame")
346 continue
347 if mode == "frame":
348 raise SvgPictureError(
349 f"Selector {selector!r} has no data-pptx-frame; pass --bounds or use --bounds-mode measure"
350 )
351 measure.append(selector)
352
353 for selector, bounds in _measure_bounds(source, measure).items() if measure else []:
354 resolved[selector] = (bounds, "browser")
355 return {
356 selector: (_padded_bounds(bounds, padding), source_name)
357 for selector, (bounds, source_name) in resolved.items()
358 }
359
360
361 def _referenced_ids(elem: ET.Element) -> set[str]:
362 refs: set[str] = set()
363 for item in elem.iter():
364 for attr_name, value in item.attrib.items():
365 refs.update(
366 match.group(2)[1:]
367 for match in _URL_RE.finditer(value)
368 if match.group(2).startswith("#")
369 )
370 if _local(attr_name) == "href" and value.startswith("#") and len(value) > 1:
371 refs.add(value[1:])
372 if item.text:
373 refs.update(
374 match.group(2)[1:]
375 for match in _URL_RE.finditer(item.text)
376 if match.group(2).startswith("#")
377 )
378 return refs
379
380
381 def _visual_clone(
382 root: ET.Element,
383 target: ET.Element,
384 parents: dict[ET.Element, ET.Element],
385 ) -> tuple[ET.Element, set[ET.Element]]:
386 clone = copy.deepcopy(target)
387 included = {target}
388 cursor = target
389 while parents.get(cursor) is not root:
390 ancestor = parents.get(cursor)
391 if ancestor is None or _local(ancestor.tag) != "g":
392 raise SvgPictureError("Selected group is not in the visible SVG tree")
393 unsafe_attrs = [
394 _local(name)
395 for name in ancestor.attrib
396 if not (
397 _local(name) == "id"
398 or _local(name) == "role"
399 or _local(name).startswith("data-")
400 or _local(name).startswith("aria-")
401 )
402 ]
403 semantic_attrs = _semantic_markers(ancestor)
404 if unsafe_attrs or semantic_attrs:
405 details = ", ".join(sorted(set(unsafe_attrs + semantic_attrs)))
406 raise SvgPictureError(
407 "Selected group has a non-neutral ancestor "
408 f"{ancestor.get('id') or '<g>'} ({details}); select that outer group instead"
409 )
410 shell = ET.Element(ancestor.tag, dict(ancestor.attrib))
411 shell.append(clone)
412 clone = shell
413 included.add(ancestor)
414 cursor = ancestor
415 return clone, included
416
417
418 def _dependency_elements(
419 root: ET.Element,
420 target: ET.Element,
421 visual: ET.Element,
422 included: set[ET.Element],
423 parents: dict[ET.Element, ET.Element],
424 ) -> list[ET.Element]:
425 by_id = _index_ids(root)
426 queue = sorted(_referenced_ids(visual))
427 dependencies: list[ET.Element] = []
428 seen: set[str] = set()
429 while queue:
430 ref_id = queue.pop(0)
431 if ref_id in seen:
432 continue
433 seen.add(ref_id)
434 dependency = by_id.get(ref_id)
435 if dependency is None:
436 raise SvgPictureError(f"Referenced SVG definition #{ref_id} does not exist")
437 if dependency in included or _is_descendant(target, dependency):
438 continue
439 dependencies.append(dependency)
440 queue.extend(sorted(_referenced_ids(dependency) - seen))
441
442 dependency_set = set(dependencies)
443 return [
444 dependency
445 for dependency in dependencies
446 if not any(ancestor in dependency_set for ancestor in _ancestors(dependency, parents))
447 ]
448
449
450 def _ancestors(
451 elem: ET.Element,
452 parents: dict[ET.Element, ET.Element],
453 ) -> list[ET.Element]:
454 found: list[ET.Element] = []
455 cursor = elem
456 while cursor in parents:
457 cursor = parents[cursor]
458 found.append(cursor)
459 return found
460
461
462 def _data_uri(resource: Path) -> str:
463 try:
464 payload = resource.read_bytes()
465 except OSError as exc:
466 raise SvgPictureError(f"Cannot embed local SVG resource {resource}: {exc}") from exc
467 mime = mimetypes.guess_type(resource.name)[0] or "application/octet-stream"
468 encoded = base64.b64encode(payload).decode("ascii")
469 return f"data:{mime};base64,{encoded}"
470
471
472 def _embed_reference(
473 value: str,
474 source_dir: Path,
475 resource_root: Path,
476 embedded: list[str],
477 ) -> str:
478 raw = value.strip()
479 if not raw or raw.startswith(("#", "data:")):
480 return value
481 parsed = urlsplit(raw)
482 _validate_resource_value(raw, allow_fragment=True)
483 resource = (source_dir / unquote(parsed.path)).resolve()
484 try:
485 relative_resource = resource.relative_to(resource_root)
486 except ValueError as exc:
487 raise SvgPictureError(f"Local SVG resource escapes --resource-root: {raw}") from exc
488 if not resource.is_file():
489 raise SvgPictureError(f"Local SVG resource does not exist: {raw}")
490 embedded.append(relative_resource.as_posix())
491 return _data_uri(resource)
492
493
494 def _embed_css_urls(
495 value: str,
496 source_dir: Path,
497 resource_root: Path,
498 embedded: list[str],
499 ) -> str:
500 def replace(match: re.Match[str]) -> str:
501 reference = match.group(2)
502 if reference.startswith(("#", "data:")):
503 return match.group(0)
504 embedded_reference = _embed_reference(
505 reference,
506 source_dir,
507 resource_root,
508 embedded,
509 )
510 return f'url("{embedded_reference}")'
511
512 return _URL_RE.sub(replace, value)
513
514
515 def _embed_external_resources(
516 asset_root: ET.Element,
517 source_dir: Path,
518 resource_root: Path,
519 ) -> list[str]:
520 embedded: list[str] = []
521 for elem in asset_root.iter():
522 tag = _local(elem.tag)
523 for attr_name, value in list(elem.attrib.items()):
524 rewritten = _embed_css_urls(value, source_dir, resource_root, embedded)
525 if _local(attr_name) == "href" and tag in {"image", "feImage"}:
526 rewritten = _embed_reference(
527 rewritten,
528 source_dir,
529 resource_root,
530 embedded,
531 )
532 elif _local(attr_name) == "href" and tag == "use" and not rewritten.startswith("#"):
533 raise SvgPictureError(f"External <use> is unsupported in a picture asset: {rewritten}")
534 if rewritten != value:
535 elem.set(attr_name, rewritten)
536 if elem.text:
537 elem.text = _embed_css_urls(
538 elem.text,
539 source_dir,
540 resource_root,
541 embedded,
542 )
543 return sorted(set(embedded))
544
545
546 def _rebase_reference(
547 value: str,
548 source_dir: Path,
549 output_dir: Path,
550 resource_root: Path,
551 ) -> str:
552 raw = value.strip()
553 if not raw or raw.startswith(("#", "data:")):
554 return value
555 parsed = urlsplit(raw)
556 if parsed.scheme or parsed.netloc or not parsed.path:
557 return value
558 resource = (source_dir / unquote(parsed.path)).resolve()
559 try:
560 resource.relative_to(resource_root)
561 except ValueError as exc:
562 raise SvgPictureError(f"Local page resource escapes --resource-root: {raw}") from exc
563 relative = Path(os.path.relpath(resource, output_dir)).as_posix()
564 return urlunsplit(("", "", relative, parsed.query, parsed.fragment))
565
566
567 def _rebase_css_urls(
568 value: str,
569 source_dir: Path,
570 output_dir: Path,
571 resource_root: Path,
572 ) -> str:
573 def replace(match: re.Match[str]) -> str:
574 reference = match.group(2)
575 rewritten = _rebase_reference(
576 reference,
577 source_dir,
578 output_dir,
579 resource_root,
580 )
581 if rewritten == reference:
582 return match.group(0)
583 quote = match.group(1)
584 return f"url({quote}{rewritten}{quote})"
585
586 return _URL_RE.sub(replace, value)
587
588
589 def _rebase_page_resources(
590 root: ET.Element,
591 source_dir: Path,
592 output_dir: Path,
593 resource_root: Path,
594 ) -> None:
595 if source_dir == output_dir:
596 return
597 for elem in root.iter():
598 tag = _local(elem.tag)
599 for attr_name, value in list(elem.attrib.items()):
600 rewritten = _rebase_css_urls(
601 value,
602 source_dir,
603 output_dir,
604 resource_root,
605 )
606 if _local(attr_name) == "href" and tag in {"image", "feImage", "use"}:
607 rewritten = _rebase_reference(
608 rewritten,
609 source_dir,
610 output_dir,
611 resource_root,
612 )
613 if rewritten != value:
614 elem.set(attr_name, rewritten)
615 if elem.text:
616 elem.text = _rebase_css_urls(
617 elem.text,
618 source_dir,
619 output_dir,
620 resource_root,
621 )
622
623
624 def _asset_root_attributes(source_root: ET.Element, bounds: Bounds) -> dict[str, str]:
625 attrs = {
626 name: value
627 for name, value in source_root.attrib.items()
628 if _local(name) not in GEOMETRY_ROOT_ATTRS
629 }
630 attrs.update(
631 {
632 "viewBox": f"0 0 {_fmt(bounds.width)} {_fmt(bounds.height)}",
633 "width": _fmt(bounds.width),
634 "height": _fmt(bounds.height),
635 }
636 )
637 return attrs
638
639
640 def _build_asset(
641 source: Path,
642 resource_root: Path,
643 root: ET.Element,
644 target: ET.Element,
645 bounds: Bounds,
646 parents: dict[ET.Element, ET.Element],
647 ) -> tuple[bytes, list[str], list[str]]:
648 visual, included = _visual_clone(root, target, parents)
649 dependencies = _dependency_elements(root, target, visual, included, parents)
650 styles = [
651 elem
652 for elem in root.iter()
653 if _local(elem.tag) == "style"
654 and not _is_descendant(target, elem)
655 and not any(_is_descendant(dependency, elem) for dependency in dependencies)
656 ]
657 asset_root = ET.Element(root.tag, _asset_root_attributes(root, bounds))
658 if styles or dependencies:
659 defs = ET.SubElement(asset_root, f"{{{SVG_NS}}}defs")
660 for style in styles:
661 defs.append(copy.deepcopy(style))
662 for dependency in dependencies:
663 if _local(dependency.tag) != "style":
664 defs.append(copy.deepcopy(dependency))
665 translated = ET.SubElement(
666 asset_root,
667 f"{{{SVG_NS}}}g",
668 {"transform": f"translate({_fmt(-bounds.x)} {_fmt(-bounds.y)})"},
669 )
670 translated.append(visual)
671 embedded = _embed_external_resources(asset_root, source.parent, resource_root)
672 ET.indent(asset_root, space=" ")
673 payload = ET.tostring(asset_root, encoding="utf-8", xml_declaration=True)
674 dependency_ids = sorted(filter(None, (elem.get("id") for elem in dependencies)))
675 return payload, dependency_ids, embedded
676
677
678 def _safe_asset_name(source: Path, selector: str) -> str:
679 safe_id = re.sub(r"[^A-Za-z0-9._-]+", "-", selector).strip(".-") or "asset"
680 safe_stem = re.sub(r"[^A-Za-z0-9._-]+", "-", source.stem).strip(".-") or "source"
681 return f"{safe_stem}__{safe_id}.svg"
682
683
684 def _validate_asset_name(name: str, selector: str) -> str:
685 path = Path(name)
686 if path.name != name or path.suffix.lower() != ".svg":
687 raise SvgPictureError(f"Asset name for {selector!r} must be one .svg basename")
688 return name
689
690
691 def _replacement_image(target: ET.Element, bounds: Bounds, href: str) -> ET.Element:
692 attrs = {
693 name: value
694 for name, value in target.attrib.items()
695 if _local(name) in PRESERVED_IMAGE_ATTRS
696 }
697 attrs.update(
698 {
699 "href": href,
700 "x": _fmt(bounds.x),
701 "y": _fmt(bounds.y),
702 "width": _fmt(bounds.width),
703 "height": _fmt(bounds.height),
704 "preserveAspectRatio": "none",
705 }
706 )
707 return ET.Element(f"{{{SVG_NS}}}image", attrs)
708
709
710 def _prepare_plans(
711 source: Path,
712 resource_root: Path,
713 output: Path,
714 images_dir: Path,
715 root: ET.Element,
716 targets: dict[str, ET.Element],
717 resolved_bounds: dict[str, tuple[Bounds, str]],
718 names: dict[str, str],
719 parents: dict[ET.Element, ET.Element],
720 ) -> list[ExtractionPlan]:
721 plans: list[ExtractionPlan] = []
722 for selector, target in targets.items():
723 bounds, bounds_source = resolved_bounds[selector]
724 name = _validate_asset_name(
725 names.get(selector, _safe_asset_name(source, selector)),
726 selector,
727 )
728 asset_path = images_dir / name
729 href = Path(os.path.relpath(asset_path, output.parent)).as_posix()
730 payload, dependency_ids, embedded = _build_asset(
731 source,
732 resource_root,
733 root,
734 target,
735 bounds,
736 parents,
737 )
738 plans.append(
739 ExtractionPlan(
740 selector=selector,
741 asset_path=asset_path,
742 href=href,
743 bounds=bounds,
744 bounds_source=bounds_source,
745 asset_bytes=payload,
746 dependency_ids=dependency_ids,
747 embedded_resources=embedded,
748 )
749 )
750 asset_paths = [plan.asset_path for plan in plans]
751 if len(set(asset_paths)) != len(asset_paths):
752 raise SvgPictureError("Generated or overridden asset filenames must be unique")
753 return plans
754
755
756 def _check_outputs(
757 source: Path,
758 output: Path,
759 inventory: Path,
760 plans: list[ExtractionPlan],
761 overwrite: bool,
762 ) -> None:
763 if output != source and output.exists() and not overwrite:
764 raise SvgPictureError(f"Output SVG exists; pass --overwrite to replace it: {output}")
765 if inventory.exists() and not overwrite:
766 raise SvgPictureError(f"Inventory exists; pass --overwrite to replace it: {inventory}")
767 for plan in plans:
768 if not plan.asset_path.exists():
769 continue
770 if plan.asset_path.read_bytes() != plan.asset_bytes and not overwrite:
771 raise SvgPictureError(
772 f"Picture asset exists with different bytes; pass --overwrite: {plan.asset_path}"
773 )
774
775
776 def _replace_targets(
777 targets: dict[str, ET.Element],
778 plans: list[ExtractionPlan],
779 parents: dict[ET.Element, ET.Element],
780 ) -> None:
781 for plan in plans:
782 target = targets[plan.selector]
783 parent = parents[target]
784 index = list(parent).index(target)
785 parent.remove(target)
786 parent.insert(index, _replacement_image(target, plan.bounds, plan.href))
787
788
789 def _inventory_payload(
790 source: Path,
791 resource_root: Path,
792 output: Path,
793 images_dir: Path,
794 plans: list[ExtractionPlan],
795 ) -> bytes:
796 payload = {
797 "schema": "svg_picture_asset_inventory.v1",
798 "sourceSvg": str(source),
799 "resourceRoot": str(resource_root),
800 "rewrittenSvg": str(output),
801 "imagesDir": str(images_dir),
802 "items": [
803 {
804 "selector": plan.selector,
805 "asset": str(plan.asset_path),
806 "href": plan.href,
807 "bounds": [
808 plan.bounds.x,
809 plan.bounds.y,
810 plan.bounds.width,
811 plan.bounds.height,
812 ],
813 "boundsSource": plan.bounds_source,
814 "sha256": hashlib.sha256(plan.asset_bytes).hexdigest(),
815 "dependencyIds": plan.dependency_ids,
816 "embeddedResources": plan.embedded_resources,
817 }
818 for plan in plans
819 ],
820 }
821 return (json.dumps(payload, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
822
823
824 def _write_outputs(
825 tree: ET.ElementTree,
826 output: Path,
827 inventory: Path,
828 images_dir: Path,
829 plans: list[ExtractionPlan],
830 inventory_bytes: bytes,
831 ) -> None:
832 images_dir.mkdir(parents=True, exist_ok=True)
833 output.parent.mkdir(parents=True, exist_ok=True)
834 inventory.parent.mkdir(parents=True, exist_ok=True)
835 for plan in plans:
836 plan.asset_path.write_bytes(plan.asset_bytes)
837 ET.indent(tree, space=" ")
838 output.write_bytes(ET.tostring(tree.getroot(), encoding="utf-8", xml_declaration=True))
839 inventory.write_bytes(inventory_bytes)
840
841
842 def build_parser() -> argparse.ArgumentParser:
843 parser = argparse.ArgumentParser(
844 description="Extract explicitly selected SVG groups as standalone SVG picture assets.",
845 formatter_class=argparse.RawDescriptionHelpFormatter,
846 )
847 parser.add_argument("svg_file", type=Path, help="Source SVG file")
848 parser.add_argument(
849 "--select",
850 action="append",
851 required=True,
852 metavar="ID",
853 help="Exact <g id> to extract; repeat for multiple groups",
854 )
855 parser.add_argument("--images-dir", required=True, type=Path, help="Destination image asset directory")
856 parser.add_argument(
857 "--resource-root",
858 type=Path,
859 help="Allowed root for local SVG dependencies (default: source directory)",
860 )
861 output = parser.add_mutually_exclusive_group(required=True)
862 output.add_argument("-o", "--output", type=Path, help="Rewritten SVG path")
863 output.add_argument("--inplace", action="store_true", help="Rewrite the source SVG")
864 parser.add_argument(
865 "--bounds",
866 action="append",
867 default=[],
868 metavar="ID=X,Y,W,H",
869 help="Explicit tight bounds for one selector; repeat as needed",
870 )
871 parser.add_argument(
872 "--bounds-mode",
873 choices=("auto", "frame", "measure"),
874 default="auto",
875 help="Bounds source when --bounds is absent (default: auto)",
876 )
877 parser.add_argument("--padding", type=float, default=0.0, help="Non-negative padding in SVG units")
878 parser.add_argument(
879 "--asset-name",
880 action="append",
881 default=[],
882 metavar="ID=NAME.svg",
883 help="Override one generated asset filename",
884 )
885 parser.add_argument("--inventory", type=Path, help="Explicit JSON inventory path")
886 parser.add_argument("--overwrite", action="store_true", help="Replace existing non-identical outputs")
887 return parser
888
889
890 def _run(args: argparse.Namespace) -> dict[str, object]:
891 source = args.svg_file.resolve()
892 if not source.is_file():
893 raise SvgPictureError(f"Source SVG does not exist: {source}")
894 if args.padding < 0 or not math.isfinite(args.padding):
895 raise SvgPictureError("--padding must be a finite non-negative number")
896 selectors = [selector.strip() for selector in args.select]
897 if any(not selector for selector in selectors) or len(set(selectors)) != len(selectors):
898 raise SvgPictureError("--select values must be non-empty and unique")
899 print(
900 f"Preparing {len(selectors)} explicit SVG picture asset(s) from {source.name}",
901 file=sys.stderr,
902 )
903
904 output = source if args.inplace else args.output.resolve()
905 if not args.inplace and output == source:
906 raise SvgPictureError("Use --inplace explicitly when the output is the source SVG")
907 images_dir = args.images_dir.resolve()
908 resource_root = args.resource_root.resolve() if args.resource_root else source.parent
909 if not resource_root.is_dir():
910 raise SvgPictureError(f"--resource-root is not a directory: {resource_root}")
911 try:
912 source.relative_to(resource_root)
913 except ValueError as exc:
914 raise SvgPictureError("Source SVG must be inside --resource-root") from exc
915 inventory = (
916 args.inventory.resolve()
917 if args.inventory
918 else output.with_name(f"{output.stem}_picture_asset_inventory.json")
919 )
920 if inventory in {source, output}:
921 raise SvgPictureError("Inventory path must differ from the source and rewritten SVG")
922 bounds_overrides = _parse_key_values(args.bounds, "--bounds")
923 asset_names = _parse_key_values(args.asset_name, "--asset-name")
924 unknown_options = (set(bounds_overrides) | set(asset_names)) - set(selectors)
925 if unknown_options:
926 raise SvgPictureError("Options reference unselected id(s): " + ", ".join(sorted(unknown_options)))
927
928 tree, root = _parse_svg(source)
929 _validate_source(root)
930 ids = _index_ids(root)
931 missing = [selector for selector in selectors if selector not in ids]
932 if missing:
933 raise SvgPictureError("Selected SVG id(s) do not exist: " + ", ".join(missing))
934 targets = {selector: ids[selector] for selector in selectors}
935 for selector, target in targets.items():
936 _validate_target(selector, target)
937 for outer_id, outer in targets.items():
938 for inner_id, inner in targets.items():
939 if outer_id != inner_id and _is_descendant(outer, inner):
940 raise SvgPictureError(f"Nested selections are not allowed: {outer_id!r} contains {inner_id!r}")
941
942 parents = _parent_map(root)
943 resolved = _resolve_bounds(
944 source,
945 targets,
946 bounds_overrides,
947 args.bounds_mode,
948 args.padding,
949 )
950 plans = _prepare_plans(
951 source,
952 resource_root,
953 output,
954 images_dir,
955 root,
956 targets,
957 resolved,
958 asset_names,
959 parents,
960 )
961 occupied = {source, output, inventory}
962 if any(plan.asset_path in occupied for plan in plans):
963 raise SvgPictureError("Picture asset paths must differ from SVG and inventory paths")
964 _check_outputs(source, output, inventory, plans, args.overwrite)
965 _rebase_page_resources(root, source.parent, output.parent, resource_root)
966 _replace_targets(targets, plans, parents)
967 inventory_bytes = _inventory_payload(
968 source,
969 resource_root,
970 output,
971 images_dir,
972 plans,
973 )
974 _write_outputs(tree, output, inventory, images_dir, plans, inventory_bytes)
975 summary_path: Path | None = None
976 if args.inplace and (source.parent / AUTHORING_MANIFEST_NAME).is_file():
977 try:
978 summary_path = write_authoring_summary(source.parent)
979 except (OSError, ValueError) as exc:
980 raise SvgPictureError(
981 "Picture extraction succeeded but authoring summary refresh "
982 f"failed: {exc}"
983 ) from exc
984 print(f"Wrote rewritten SVG and {len(plans)} picture asset(s)", file=sys.stderr)
985 return {
986 "source": str(source),
987 "output": str(output),
988 "inventory": str(inventory),
989 "assets": [str(plan.asset_path) for plan in plans],
990 "authoring_summary": str(summary_path) if summary_path is not None else None,
991 }
992
993
994 def main(argv: list[str] | None = None) -> int:
995 parser = build_parser()
996 try:
997 result = _run(parser.parse_args(argv))
998 except (OSError, SvgPictureError) as exc:
999 print(f"ERROR: {exc}", file=sys.stderr)
1000 return 1
1001 print(json.dumps(result, ensure_ascii=False))
1002 return 0
1003
1004
1005 if __name__ == "__main__":
1006 raise SystemExit(main())
1007
1007 lines PYTHON