#!/usr/bin/env python3 """ PPT Master - Large Vector Asset Extractor Factor large inline vector groups (complex illustrations) out of working SVGs into project icon assets, leaving a one-line `` placeholder behind — so the working SVG stays readable (structure, not a wall of ``). Visually lossless and reversible: the existing icon embedding path re-inlines each asset before export, so the exported PPTX remains native shapes, not an embedded picture. Because re-inlining restores the extracted vector subtree, the detection threshold is a readability convenience — it changes which blobs are factored out, not whether the export stays editable. Usage: python3 scripts/extract_svg_assets.py [options] Examples: python3 scripts/extract_svg_assets.py import_ws/authoring-svg \ --icons-dir import_ws/icons --icon-namespace imported \ --inplace --id-prefix layered --clean-stale python3 scripts/extract_svg_assets.py import_ws/authoring-svg-flat \ --icons-dir import_ws/icons --icon-namespace imported \ --reuse-inventory import_ws/authoring-svg_vector_asset_inventory.json \ --inplace --id-prefix flat --clean-stale python3 scripts/extract_svg_assets.py project/svg_output --inplace --min-drawables 40 Dependencies: None (standard library only). See workflows/create-template.md and svg_finalize/embed_icons.py. """ from __future__ import annotations import argparse import copy import hashlib import json import re import sys from pathlib import Path from typing import Optional from xml.etree import ElementTree as ET from console_encoding import configure_utf8_stdio from svg_authoring_view import ( AUTHORING_MANIFEST_NAME, write_authoring_summary, ) configure_utf8_stdio() SVG_NS = "http://www.w3.org/2000/svg" DRAWABLE = {"path", "polygon", "polyline", "rect", "circle", "ellipse", "line"} SEMANTIC_CONTENT = {"text", "tspan", "foreignObject"} DEFAULT_MIN_DRAWABLES = 20 DEFAULT_MIN_BYTES = 3000 DEFAULT_MIN_DECORATION_BYTES = 3000 SOURCE_REF_ATTRIBUTE = "data-pptx-source-ref" ICON_NAMESPACE_RE = re.compile(r"^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$") URL_REF_RE = re.compile(r"url\(\s*(['\"]?)#([^)'\"]\S*?)\1\s*\)") def _local(tag: object) -> str: return tag.rsplit("}", 1)[-1] if isinstance(tag, str) else "" def _drawable_count(elem: ET.Element) -> int: return sum(1 for e in elem.iter() if _local(e.tag) in DRAWABLE) def _xml_size(elem: ET.Element) -> int: if not any(item.get(SOURCE_REF_ATTRIBUTE) for item in elem.iter()): return len(ET.tostring(elem, encoding="utf-8")) measured = copy.deepcopy(elem) for item in measured.iter(): item.attrib.pop(SOURCE_REF_ATTRIBUTE, None) return len(ET.tostring(measured, encoding="utf-8")) def _large_enough(elem: ET.Element, min_drawables: int, min_bytes: int) -> bool: return _drawable_count(elem) >= min_drawables or _xml_size(elem) >= min_bytes def _has_semantic_content(elem: ET.Element) -> bool: """Text-bearing groups must stay readable/editable in the working SVG.""" return any(_local(e.tag) in SEMANTIC_CONTENT for e in elem.iter()) def _is_existing_placeholder(elem: ET.Element) -> bool: return _local(elem.tag) == "use" and elem.get("data-icon") is not None def _has_icon_placeholder(elem: ET.Element) -> bool: return any(_is_existing_placeholder(item) for item in elem.iter()) def _is_extractable_subtree(elem: ET.Element) -> bool: """Pure vector subtrees can be moved; semantic content must stay inline.""" if _has_icon_placeholder(elem) or _is_chart_group(elem) or _has_semantic_content(elem): return False return _drawable_count(elem) > 0 def _is_chart_group(elem: ET.Element) -> bool: """Charts are handled separately (data + calibration) — never extract them.""" gid = (elem.get("id") or "").lower() if "chart" in gid: return True return any("chart" in (e.get("id") or "").lower() for e in elem.iter()) def _tag_histogram(elem: ET.Element) -> dict[str, int]: hist: dict[str, int] = {} for e in elem.iter(): name = _local(e.tag) if name in DRAWABLE: hist[name] = hist.get(name, 0) + 1 return hist def _source_references(elem: ET.Element) -> list[str]: return sorted({ source_ref for item in elem.iter() if (source_ref := item.get(SOURCE_REF_ATTRIBUTE)) }) def _is_descendant(container: ET.Element, candidate: ET.Element) -> bool: return any(elem is candidate for elem in container.iter()) def _id_index(root: ET.Element) -> dict[str, ET.Element]: return {elem_id: elem for elem in root.iter() if (elem_id := elem.get("id"))} def _referenced_ids(elem: ET.Element) -> set[str]: refs: set[str] = set() for item in elem.iter(): for attr_name, value in item.attrib.items(): refs.update(match.group(2) for match in URL_REF_RE.finditer(value)) if _local(attr_name) == "href" and value.startswith("#") and len(value) > 1: refs.add(value[1:]) return refs def _dependency_elements(root: ET.Element, asset_group: ET.Element) -> list[ET.Element]: """ Return external definition elements referenced by the extracted subtree. Gradients, patterns, filters, clip paths, and markers often live in the source SVG root . The extracted asset must carry these dependencies itself; otherwise the standalone icon and later re-inline can lose styling. """ by_id = _id_index(root) dependencies: list[ET.Element] = [] seen: set[str] = set() queue = sorted(_referenced_ids(asset_group)) while queue: ref_id = queue.pop(0) if ref_id in seen: continue seen.add(ref_id) target = by_id.get(ref_id) if target is None or _is_descendant(asset_group, target): continue dependencies.append(target) for nested_ref in sorted(_referenced_ids(target)): if nested_ref not in seen: queue.append(nested_ref) return dependencies def _collect_id_mapping(asset_id: str, group: ET.Element, dependencies: list[ET.Element]) -> dict[str, str]: mapping: dict[str, str] = {} for root in [group, *dependencies]: for elem in root.iter(): elem_id = elem.get("id") if elem_id and elem_id not in mapping: mapping[elem_id] = f"{asset_id}_{elem_id}" return mapping def _rewrite_references(elem: ET.Element, id_mapping: dict[str, str]) -> None: def rewrite_url(match: re.Match[str]) -> str: quote, ref_id = match.group(1), match.group(2) new_id = id_mapping.get(ref_id, ref_id) return f"url({quote}#{new_id}{quote})" for item in elem.iter(): elem_id = item.get("id") if elem_id in id_mapping: item.set("id", id_mapping[elem_id]) for attr_name, value in list(item.attrib.items()): rewritten = URL_REF_RE.sub(rewrite_url, value) if _local(attr_name) == "href" and value.startswith("#") and value[1:] in id_mapping: rewritten = f"#{id_mapping[value[1:]]}" if rewritten != value: item.set(attr_name, rewritten) def _find_extractable(root: ET.Element, min_drawables: int, min_bytes: int) -> list[ET.Element]: """Outermost groups whose drawable count clears the threshold (no nesting).""" found: list[ET.Element] = [] def walk(elem: ET.Element) -> None: for child in list(elem): if _local(child.tag) != "g": walk(child) continue if ( not _is_chart_group(child) and not _has_semantic_content(child) and not _has_icon_placeholder(child) and _large_enough(child, min_drawables, min_bytes) ): found.append(child) # outermost qualifying — do not descend else: walk(child) walk(root) return found def _find_extractable_runs( root: ET.Element, min_drawables: int, min_bytes: int, min_decoration_bytes: int, ) -> list[tuple[ET.Element, list[ET.Element]]]: """ Consecutive pure-vector children inside mixed groups. PPT exports often flatten text and large vector decorations as siblings under the same parent. Whole-group extraction would hide text, so only the contiguous vector runs are factored out. """ found: list[tuple[ET.Element, list[ET.Element]]] = [] def flush(parent: ET.Element, run: list[ET.Element]) -> None: byte_threshold = min_decoration_bytes if _has_semantic_content(parent) else min_bytes if run and ( sum(_drawable_count(child) for child in run) >= min_drawables or sum(_xml_size(child) for child in run) >= byte_threshold ): found.append((parent, list(run))) def walk(elem: ET.Element) -> None: run: list[ET.Element] = [] for child in list(elem): if _is_extractable_subtree(child): run.append(child) continue flush(elem, run) run = [] walk(child) flush(elem, run) walk(root) return found def _asset_svg( group: ET.Element, dependencies: list[ET.Element], view_box: str | None, width: str | None, height: str | None, ) -> bytes: """Standalone, independently-viewable SVG carrying the group in page coords.""" svg = ET.Element(f"{{{SVG_NS}}}svg") svg.set("data-icon-style", "preserve-color") if view_box: svg.set("viewBox", view_box) if width: svg.set("width", width) if height: svg.set("height", height) if dependencies: defs = ET.SubElement(svg, f"{{{SVG_NS}}}defs") for dependency in dependencies: defs.append(dependency) svg.append(group) return ET.tostring(svg, encoding="utf-8", xml_declaration=True) def _source_sha256( group: ET.Element, dependencies: list[ET.Element], view_box: str | None, width: str | None, height: str | None, ) -> str: """Fingerprint an extracted subtree before asset-id namespacing.""" payload = _asset_svg( copy.deepcopy(group), [copy.deepcopy(dependency) for dependency in dependencies], view_box, width, height, ) return hashlib.sha256(payload).hexdigest() def _asset_group(nodes: list[ET.Element]) -> ET.Element: group = ET.Element(f"{{{SVG_NS}}}g") for node in nodes: group.append(node) return group def _asset_id(svg_path: Path, index: int, id_prefix: str) -> str: prefix = f"{id_prefix}_" if id_prefix else "" return f"{prefix}{svg_path.stem}_ill{index:02d}" def _icon_reference(icon_namespace: str, asset_id: str) -> str: return f"{icon_namespace}/{asset_id}" if icon_namespace else asset_id def _asset_relative_path(icon_namespace: str, asset_id: str) -> str: return f"{_icon_reference(icon_namespace, asset_id)}.svg" def _icon_asset_for_namespace(icon_name: str, icon_namespace: str) -> str | None: """Map one local placeholder to its asset path, excluding other libraries.""" if icon_namespace: prefix = f"{icon_namespace}/" if not icon_name.startswith(prefix): return None asset_id = icon_name[len(prefix):] if not asset_id or "/" in asset_id: return None return f"{icon_name}.svg" if "/" in icon_name: return None return f"{icon_name}.svg" def _has_namespace_placeholder(root: ET.Element, icon_namespace: str) -> bool: if not icon_namespace: return False return any( _local(elem.tag) == "use" and (icon_name := elem.get("data-icon")) is not None and _icon_asset_for_namespace(icon_name, icon_namespace) is not None for elem in root.iter() ) def _generated_asset_re(svg_stems: list[str], id_prefix: str) -> re.Pattern[str] | None: if not svg_stems: return None prefix = f"{re.escape(id_prefix)}_" if id_prefix else "" stems = "|".join(re.escape(stem) for stem in svg_stems) return re.compile(rf"^{prefix}(?:{stems})_ill\d+\.svg$") def _clean_stale_assets( icons_dir: Path, icon_namespace: str, svg_paths: list[Path], id_prefix: str, keep_assets: set[str], ) -> list[str]: pattern = _generated_asset_re([path.stem for path in svg_paths], id_prefix) if pattern is None: return [] removed: list[str] = [] asset_dir = icons_dir / icon_namespace if icon_namespace else icons_dir for asset_path in sorted(asset_dir.glob("*.svg")): relative_asset = asset_path.relative_to(icons_dir).as_posix() if relative_asset in keep_assets or not pattern.match(asset_path.name): continue asset_path.unlink() removed.append(relative_asset) return removed def _referenced_icon_assets(svg_paths: list[Path], icon_namespace: str) -> set[str]: assets: set[str] = set() for svg_path in svg_paths: try: root = ET.parse(svg_path).getroot() except ET.ParseError: continue for elem in root.iter(): if _local(elem.tag) != "use": continue icon_name = elem.get("data-icon") if icon_name and (asset := _icon_asset_for_namespace(icon_name, icon_namespace)): assets.add(asset) return assets def _existing_placeholder_entries( svg_paths: list[Path], icons_dir: Path, icon_namespace: str, known_assets: set[str], ) -> list[dict]: by_asset: dict[str, dict] = {} for svg_path in svg_paths: try: root = ET.parse(svg_path).getroot() except ET.ParseError: continue for elem in root.iter(): if _local(elem.tag) != "use": continue icon_name = elem.get("data-icon") if not icon_name: continue asset = _icon_asset_for_namespace(icon_name, icon_namespace) if asset is None: continue if asset in known_assets: continue entry = by_asset.setdefault( asset, { "svg": svg_path.name, "svgs": [], "id": icon_name, "icon": icon_name, "asset": asset, "source": "existing-placeholder", "asset_exists": (icons_dir / asset).exists(), }, ) if svg_path.name not in entry["svgs"]: entry["svgs"].append(svg_path.name) entries: list[dict] = [] for asset, entry in sorted(by_asset.items()): asset_path = icons_dir / asset if asset_path.exists(): entry["asset_sha256"] = hashlib.sha256( asset_path.read_bytes() ).hexdigest() try: root = ET.parse(asset_path).getroot() except ET.ParseError: pass else: entry["drawable_count"] = _drawable_count(root) entry["byte_count"] = _xml_size(root) entry["elements"] = _tag_histogram(root) entry["source_refs"] = _source_references(root) entry["dependencies"] = sorted( elem_id for elem in root.iter() if _local(elem.tag) == "defs" for child in elem if (elem_id := child.get("id")) ) entries.append(entry) return entries def _load_reusable_assets(inventory_path: Path, icons_dir: Path) -> dict[str, dict]: """Load fingerprinted assets from an earlier extraction inventory.""" try: payload = json.loads(inventory_path.read_text(encoding="utf-8")) except FileNotFoundError as exc: raise ValueError(f"reuse inventory not found: {inventory_path}") from exc except json.JSONDecodeError as exc: raise ValueError(f"invalid reuse inventory JSON: {inventory_path}: {exc}") from exc entries = payload.get("assets") if not isinstance(entries, list): raise ValueError(f"reuse inventory has no assets list: {inventory_path}") reusable: dict[str, dict] = {} fingerprinted = 0 for entry in entries: if not isinstance(entry, dict): continue source_sha256 = entry.get("source_sha256") asset_sha256 = entry.get("asset_sha256") asset = entry.get("asset") icon = entry.get("icon") if not all( isinstance(value, str) and value for value in (source_sha256, asset_sha256, asset, icon) ): continue fingerprinted += 1 asset_path = icons_dir / asset if not asset_path.is_file(): raise ValueError( f"reusable asset is missing from the target icons directory: {asset_path}" ) actual_asset_sha256 = hashlib.sha256(asset_path.read_bytes()).hexdigest() if actual_asset_sha256 != asset_sha256: raise ValueError( f"reusable asset hash does not match its inventory: {asset_path}" ) current = reusable.get(source_sha256) if current is None or asset < str(current["asset"]): reusable[source_sha256] = entry extracted_count = payload.get("extracted_count", 0) if isinstance(extracted_count, int) and extracted_count > fingerprinted: raise ValueError( "reuse inventory predates source fingerprints; rerun the source extraction " f"with the current tool: {inventory_path}" ) return reusable def _rewritten_path(svg_path: Path, rewritten_dir: Path | None, inplace: bool) -> Path: if inplace: return svg_path if rewritten_dir is None: return svg_path.parent.parent / f"{svg_path.parent.name}-rewritten" / svg_path.name return rewritten_dir / svg_path.name def extract_file( svg_path: Path, icons_dir: Path, icon_namespace: str, min_drawables: int, min_bytes: int, min_decoration_bytes: int, inplace: bool, id_prefix: str = "", rewritten_dir: Path | None = None, reusable_assets: dict[str, dict] | None = None, ) -> list[dict]: """Extract qualifying groups from one SVG. Returns inventory entries.""" ET.register_namespace("", SVG_NS) tree = ET.parse(svg_path) root = tree.getroot() view_box = root.get("viewBox") width = root.get("width") height = root.get("height") # A namespaced projection is an all-at-once readability pass. Once it owns # an asset reference, reruns inventory the existing placeholders instead of # progressively factoring their remaining parent/sibling geometry. if _has_namespace_placeholder(root, icon_namespace): if not inplace: rewritten = _rewritten_path(svg_path, rewritten_dir, inplace) rewritten.parent.mkdir(parents=True, exist_ok=True) tree.write(rewritten, encoding="utf-8", xml_declaration=True) return [] targets: list[tuple[ET.Element, list[ET.Element]]] = [] parents = {child: parent for parent in root.iter() for child in parent} group_targets = _find_extractable(root, min_drawables, min_bytes) selected_groups = set(group_targets) def inside_selected_group(elem: ET.Element) -> bool: current = elem while current in parents: current = parents[current] if current in selected_groups: return True return False for group in group_targets: parent = parents.get(group) if parent is not None: targets.append((parent, [group])) for parent, run in _find_extractable_runs(root, min_drawables, min_bytes, min_decoration_bytes): if ( parent not in selected_groups and not inside_selected_group(parent) and not any(child in selected_groups for child in run) and all(child in parent for child in run) ): targets.append((parent, run)) if not targets: if not inplace: rewritten = _rewritten_path(svg_path, rewritten_dir, inplace) rewritten.parent.mkdir(parents=True, exist_ok=True) tree.write(rewritten, encoding="utf-8", xml_declaration=True) return [] asset_dir = icons_dir / icon_namespace if icon_namespace else icons_dir asset_dir.mkdir(parents=True, exist_ok=True) entries = [] for index, (parent, nodes) in enumerate(targets, start=1): if not nodes or not all(node in parent for node in nodes): continue asset_id = _asset_id(svg_path, index, id_prefix) icon_reference = _icon_reference(icon_namespace, asset_id) asset = _asset_relative_path(icon_namespace, asset_id) pos = list(parent).index(nodes[0]) group = nodes[0] if len(nodes) == 1 and _local(nodes[0].tag) == "g" else _asset_group(nodes) dependencies = [copy.deepcopy(elem) for elem in _dependency_elements(root, group)] dependency_source_ids = sorted({ elem_id for dependency in dependencies for elem in dependency.iter() if (elem_id := elem.get("id")) }) source_sha256 = _source_sha256(group, dependencies, view_box, width, height) source_refs = _source_references(group) reusable = (reusable_assets or {}).get(source_sha256) if reusable is not None: reused_icon = str(reusable["icon"]) reused_asset = str(reusable["asset"]) placeholder = ET.Element(f"{{{SVG_NS}}}use") placeholder.set("data-icon", reused_icon) for node in nodes: if node in parent: parent.remove(node) parent.insert(pos, placeholder) entries.append({ "svg": svg_path.name, "id": reused_icon, "icon": reused_icon, "asset": reused_asset, "source": "reused-inventory", "source_sha256": source_sha256, "asset_sha256": reusable["asset_sha256"], "reused_from_svg": reusable.get("svg"), "drawable_count": _drawable_count(group), "byte_count": _xml_size(group), "source_refs": source_refs, "dependencies": dependency_source_ids, "elements": _tag_histogram(group), }) continue id_mapping = _collect_id_mapping(asset_id, group, dependencies) _rewrite_references(group, id_mapping) for dependency in dependencies: _rewrite_references(dependency, id_mapping) # Asset keeps the group in original page coordinates and carries its defs. asset_bytes = _asset_svg(group, dependencies, view_box, width, height) (icons_dir / asset).write_bytes(asset_bytes) placeholder = ET.Element(f"{{{SVG_NS}}}use") placeholder.set("data-icon", icon_reference) for node in nodes: if node in parent: parent.remove(node) parent.insert(pos, placeholder) entries.append({ "svg": svg_path.name, "id": icon_reference, "icon": icon_reference, "asset": asset, "source": "extracted", "source_sha256": source_sha256, "asset_sha256": hashlib.sha256(asset_bytes).hexdigest(), "drawable_count": _drawable_count(group), "byte_count": _xml_size(group), "source_refs": source_refs, "dependencies": [id_mapping.get(elem_id, elem_id) for elem_id in dependency_source_ids], "elements": _tag_histogram(group), }) rewritten = _rewritten_path(svg_path, rewritten_dir, inplace) rewritten.parent.mkdir(parents=True, exist_ok=True) tree.write(rewritten, encoding="utf-8", xml_declaration=True) return entries def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Factor large inline vector groups out of SVGs into reusable assets.", formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument("svg_dir", help="Directory of working SVGs (e.g. import_ws/svg or project/svg_output)") parser.add_argument("-o", "--output", dest="icons_dir", help="Project icon dir (default: /../icons)") parser.add_argument("--icons-dir", dest="icons_dir", help="Project icon dir (default: /../icons)") parser.add_argument( "--icon-namespace", default="", help=( "Optional lower-case subdirectory and data-icon prefix for extracted " "assets (create-template uses: imported)" ), ) parser.add_argument( "--rewritten-dir", help="Directory for rewritten SVGs when not using --inplace (default: /../-rewritten)", ) parser.add_argument( "--inventory", help="Inventory JSON path (default: /../_vector_asset_inventory.json)", ) parser.add_argument( "--reuse-inventory", help=( "Reuse fingerprint-matched assets from an earlier extraction inventory; " "only unmatched vector subtrees create new assets" ), ) parser.add_argument( "--id-prefix", default="", help=( "Optional prefix for generated asset IDs, useful when processing " "layered and flat SVG dirs into one icons dir" ), ) parser.add_argument( "--min-drawables", type=int, default=DEFAULT_MIN_DRAWABLES, help=f"Min drawable elements for a group to be extracted (default: {DEFAULT_MIN_DRAWABLES})", ) parser.add_argument( "--min-bytes", type=int, default=DEFAULT_MIN_BYTES, help=f"Min XML bytes for a pure-vector group/run to be extracted (default: {DEFAULT_MIN_BYTES})", ) parser.add_argument( "--min-decoration-bytes", type=int, default=DEFAULT_MIN_DECORATION_BYTES, help=( "Min XML bytes for pure-vector decoration runs inside text-bearing groups " f"(default: {DEFAULT_MIN_DECORATION_BYTES})" ), ) parser.add_argument( "--inplace", action="store_true", help="Rewrite the source SVGs in place instead of writing to --rewritten-dir", ) parser.add_argument( "--clean-stale", action="store_true", help=( "Remove stale generated assets for the current svg filenames/id prefix " "that are not referenced by this run's inventory" ), ) return parser def main(argv: Optional[list[str]] = None) -> int: args = build_parser().parse_args(argv) svg_dir = Path(args.svg_dir) if not svg_dir.is_dir(): print(f"[ERROR] svg_dir not found: {svg_dir}", file=sys.stderr) return 1 icons_dir = Path(args.icons_dir) if args.icons_dir else svg_dir.parent / "icons" icons_dir.mkdir(parents=True, exist_ok=True) icon_namespace = args.icon_namespace.strip() if icon_namespace and not ICON_NAMESPACE_RE.fullmatch(icon_namespace): print( "[ERROR] --icon-namespace must be one lower-case ASCII directory name " "using only letters, digits, '_' or '-'", file=sys.stderr, ) return 1 reusable_assets: dict[str, dict] = {} reuse_inventory_path = Path(args.reuse_inventory) if args.reuse_inventory else None if reuse_inventory_path is not None: try: reusable_assets = _load_reusable_assets(reuse_inventory_path, icons_dir) except ValueError as exc: print(f"[ERROR] {exc}", file=sys.stderr) return 1 rewritten_dir = Path(args.rewritten_dir) if args.rewritten_dir else None inventory_path = ( Path(args.inventory) if args.inventory else svg_dir.parent / f"{svg_dir.name}_vector_asset_inventory.json" ) svg_paths = sorted(svg_dir.glob("*.svg")) inventory: list[dict] = [] for svg_path in svg_paths: try: inventory.extend( extract_file( svg_path, icons_dir, icon_namespace, args.min_drawables, args.min_bytes, args.min_decoration_bytes, args.inplace, args.id_prefix, rewritten_dir, reusable_assets, ) ) except ET.ParseError as exc: print(f"[WARN] skip unparseable {svg_path.name}: {exc}", file=sys.stderr) extracted_count = sum(entry.get("source") == "extracted" for entry in inventory) reused_count = sum(entry.get("source") == "reused-inventory" for entry in inventory) known_assets = {str(entry["asset"]) for entry in inventory} inventory.extend( _existing_placeholder_entries( svg_paths, icons_dir, icon_namespace, known_assets, ) ) stale_removed: list[str] = [] if args.clean_stale: keep_assets = {str(entry["asset"]) for entry in inventory} keep_assets.update(_referenced_icon_assets(svg_paths, icon_namespace)) stale_removed = _clean_stale_assets( icons_dir, icon_namespace, svg_paths, args.id_prefix, keep_assets, ) manifest = { "schema": "vector_asset_inventory.v1", "svg_dir": str(svg_dir), "icons_dir": str(icons_dir), "icon_namespace": icon_namespace or None, "rewritten_dir": ( None if args.inplace else str(_rewritten_path(svg_dir / "_sample.svg", rewritten_dir, False).parent) ), "reuse_inventory": str(reuse_inventory_path) if reuse_inventory_path is not None else None, "min_drawables": args.min_drawables, "min_bytes": args.min_bytes, "min_decoration_bytes": args.min_decoration_bytes, "extracted_count": extracted_count, "reused_count": reused_count, "asset_count": len(inventory), "stale_removed": stale_removed, "assets": inventory, } inventory_path.parent.mkdir(parents=True, exist_ok=True) inventory_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") summary_path: Path | None = None if args.inplace and (svg_dir / AUTHORING_MANIFEST_NAME).is_file(): try: summary_path = write_authoring_summary(svg_dir) except (OSError, ValueError) as exc: print( f"[ERROR] vector extraction succeeded but authoring summary " f"refresh failed: {exc}", file=sys.stderr, ) return 1 print( f"[OK] extracted {extracted_count} new asset(s), reused {reused_count} asset(s), " f"inventoried {len(inventory)} asset reference(s) -> " f"{icons_dir / icon_namespace if icon_namespace else icons_dir}", file=sys.stderr, ) if stale_removed: print(f"[OK] removed {len(stale_removed)} stale generated asset(s)", file=sys.stderr) if summary_path is not None: print(f"[OK] refreshed model-readable summary: {summary_path}", file=sys.stderr) return 0 if __name__ == "__main__": raise SystemExit(main())