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