#!/usr/bin/env python3 """ PPT Master - SVG Authoring View Create a lightweight, non-destructive editable IR from PPTX-imported SVG files. The source SVG remains the native-payload authority; the authoring copy keeps visible SVG content, compact shape intent, and stable source references while hiding bulky import-only payloads and duplicate hidden geometry carriers. Usage: python3 scripts/svg_authoring_view.py \ -o --projection-kind Examples: python3 scripts/svg_authoring_view.py analysis/source_svg_import/svg \ -o analysis/authoring-svg --projection-kind layered python3 scripts/svg_authoring_view.py imported/slide_06.svg \ -o /tmp/slide-authoring-view --projection-kind generic Dependencies: None (standard library only). The output directory is an authoring bundle: editable SVGs plus one model-readable `authoring_summary.json` and one tool-only `authoring_manifest.json` provenance sidecar. It is the template-creation input, not a release SVG directory; final templates are materialized from this IR. Directory runs prepare and stage the complete batch before publishing it, so a failed page leaves the existing destination set unchanged. """ from __future__ import annotations import argparse import hashlib import json import os import shutil import sys import tempfile from collections import Counter from dataclasses import dataclass, field from pathlib import Path from typing import Optional from urllib.parse import urlsplit, urlunsplit from xml.etree import ElementTree as ET from compact_svg_coordinates import compact_svg_tree from console_encoding import configure_utf8_stdio configure_utf8_stdio() SVG_NS = "http://www.w3.org/2000/svg" XLINK_NS = "http://www.w3.org/1999/xlink" AUTHORING_MANIFEST_NAME = "authoring_manifest.json" AUTHORING_SUMMARY_NAME = "authoring_summary.json" AUTHORING_SCHEMA = "ppt-master.svg-authoring-ir.v1" AUTHORING_SUMMARY_SCHEMA = "ppt-master.svg-authoring-summary.v1" SOURCE_REF_ATTRIBUTE = "data-pptx-source-ref" _DRAWABLE_TAGS = frozenset({ "circle", "ellipse", "line", "path", "polygon", "polyline", "rect", }) ET.register_namespace("", SVG_NS) ET.register_namespace("xlink", XLINK_NS) # These fields identify the source OOXML object or guard its exact imported # fallback. They belong in the complete import SVG, not its lightweight view. IMPORT_SOURCE_ATTRIBUTES = { "data-name", "data-pptx-preview-sha256", "data-pptx-shape-id", "data-pptx-shape-name", "data-pptx-shape-scope", "data-pptx-shape-style", } # Compact native-shape intent is intentionally not in the removal set: # data-pptx-object, data-pptx-prst, and data-pptx-frame remain useful while # reviewing the visible fallback. Structural markers also pass through # unchanged; the IR records identity but never decides payload-restoration # policy. def _local_name(name: object) -> str: return name.rsplit("}", 1)[-1] if isinstance(name, str) else "" @dataclass class SourceReference: source_ref: str source_path: tuple[int, ...] initial_authoring_subtree_sha256: str | None = None def as_dict(self) -> dict[str, object]: return { "source_path": list(self.source_path), "initial_authoring_subtree_sha256": self.initial_authoring_subtree_sha256, } def _semantic_text(value: str | None) -> str | None: if value is None or not value.strip(): return None return value def _stable_tag_name(tag: object) -> str: if isinstance(tag, str): return tag if tag is ET.Comment: return "#comment" if tag is ET.ProcessingInstruction: return "#processing-instruction" raise ValueError(f"Unsupported XML node type in source object: {tag!r}") def semantic_subtree_sha256( element: ET.Element, *, ignored_attributes: frozenset[str] = frozenset(), ) -> str: """Hash parsed SVG semantics without attribute order or indentation noise.""" digest = hashlib.sha256() def visit(item: ET.Element) -> None: digest.update(_stable_tag_name(item.tag).encode("utf-8")) for name, value in sorted(item.attrib.items()): if name in ignored_attributes: continue digest.update(b"\0a") digest.update(name.encode("utf-8")) digest.update(b"\0") digest.update(value.encode("utf-8")) text = _semantic_text(item.text) if text is not None: digest.update(b"\0t") digest.update(text.encode("utf-8")) for child in item: digest.update(b"\0c") visit(child) tail = _semantic_text(child.tail) if tail is not None: digest.update(b"\0l") digest.update(tail.encode("utf-8")) digest.update(b"\0e") visit(element) return digest.hexdigest() def _iter_element_paths( root: ET.Element, ) -> list[tuple[tuple[int, ...], ET.Element]]: indexed: list[tuple[tuple[int, ...], ET.Element]] = [] def walk(element: ET.Element, path: tuple[int, ...]) -> None: indexed.append((path, element)) for index, child in enumerate(element): walk(child, (*path, index)) walk(root, ()) return indexed def _source_reference(element: ET.Element) -> str | None: if not element.get("id") or not element.get("data-pptx-object"): return None scope = element.get("data-pptx-shape-scope") shape_id = element.get("data-pptx-shape-id") if not scope or not shape_id: return None return f"{scope}:{shape_id}" def _stamp_source_references(root: ET.Element) -> list[SourceReference]: existing = [ element for _, element in _iter_element_paths(root) if element.get(SOURCE_REF_ATTRIBUTE) is not None ] if existing: raise ValueError( f"Input already contains reserved {SOURCE_REF_ATTRIBUTE}; " "project from the lossless import SVG instead" ) references: list[SourceReference] = [] seen: set[str] = set() for path, element in _iter_element_paths(root): source_ref = _source_reference(element) if source_ref is None: continue if source_ref in seen: raise ValueError(f"Duplicate source object identity: {source_ref}") seen.add(source_ref) references.append( SourceReference( source_ref=source_ref, source_path=path, ) ) element.set(SOURCE_REF_ATTRIBUTE, source_ref) return references def _index_initial_authoring_references( root: ET.Element, references: list[SourceReference], ) -> None: by_ref = {reference.source_ref: reference for reference in references} seen: set[str] = set() for element in root.iter(): source_ref = element.get(SOURCE_REF_ATTRIBUTE) if source_ref is None: continue if source_ref in seen: raise ValueError(f"Duplicate authoring source reference: {source_ref}") reference = by_ref.get(source_ref) if reference is None: raise ValueError(f"Unknown authoring source reference: {source_ref}") seen.add(source_ref) reference.initial_authoring_subtree_sha256 = semantic_subtree_sha256( element, ignored_attributes=frozenset({SOURCE_REF_ATTRIBUTE}), ) missing = sorted(set(by_ref) - seen) if missing: raise ValueError( "Authoring projection dropped source-referenced object(s): " + ", ".join(missing[:5]) ) @dataclass class ProjectionStats: txbody_metadata: int = 0 hidden_geometry_carriers: int = 0 geometry_preview_wrappers: int = 0 geometry_detail_markers: int = 0 asset_references_rewritten: int = 0 coordinate_attributes_compacted: int = 0 source_attributes: Counter[str] = field(default_factory=Counter) def as_dict(self) -> dict[str, object]: return { "txbody_metadata": self.txbody_metadata, "hidden_geometry_carriers": self.hidden_geometry_carriers, "geometry_preview_wrappers": self.geometry_preview_wrappers, "geometry_detail_markers": self.geometry_detail_markers, "source_attributes": dict(sorted(self.source_attributes.items())), "asset_references_rewritten": self.asset_references_rewritten, "coordinate_attributes_compacted": self.coordinate_attributes_compacted, } def merge(self, other: "ProjectionStats") -> None: self.txbody_metadata += other.txbody_metadata self.hidden_geometry_carriers += other.hidden_geometry_carriers self.geometry_preview_wrappers += other.geometry_preview_wrappers self.geometry_detail_markers += other.geometry_detail_markers self.asset_references_rewritten += other.asset_references_rewritten self.coordinate_attributes_compacted += ( other.coordinate_attributes_compacted ) self.source_attributes.update(other.source_attributes) @dataclass class ProjectionReport: source: Path output: Path original_bytes: int projected_bytes: int stats: ProjectionStats source_sha256: str initial_authoring_sha256: str source_references: list[SourceReference] def as_dict(self) -> dict[str, object]: saved = self.original_bytes - self.projected_bytes reduction = (saved / self.original_bytes * 100) if self.original_bytes else 0.0 return { "source": str(self.source), "output": str(self.output), "original_bytes": self.original_bytes, "projected_bytes": self.projected_bytes, "bytes_saved": saved, "reduction_percent": round(reduction, 2), "source_sha256": self.source_sha256, "initial_authoring_sha256": self.initial_authoring_sha256, "source_ref_count": len(self.source_references), "removed": self.stats.as_dict(), } def _is_hidden_geometry_carrier(element: ET.Element) -> bool: if element.get("data-pptx-part") != "geometry": return False visibility = (element.get("visibility") or "").strip().lower() display = (element.get("display") or "").strip().lower() style = (element.get("style") or "").replace(" ", "").lower() return ( visibility == "hidden" or display == "none" or "visibility:hidden" in style or "display:none" in style ) def _append_tail(parent: ET.Element, index: int, tail: str | None) -> None: if not tail: return if index > 0: previous = list(parent)[index - 1] previous.tail = (previous.tail or "") + tail else: parent.text = (parent.text or "") + tail def _remove_child(parent: ET.Element, child: ET.Element) -> None: children = list(parent) index = children.index(child) tail = child.tail parent.remove(child) _append_tail(parent, index, tail) def _unwrap_preview(parent: ET.Element, wrapper: ET.Element) -> bool: """Promote a marker-only preview wrapper without changing its geometry.""" if wrapper.attrib or (wrapper.text and wrapper.text.strip()): return False siblings = list(parent) index = siblings.index(wrapper) promoted = list(wrapper) wrapper_tail = wrapper.tail for child in promoted: wrapper.remove(child) parent.remove(wrapper) for offset, child in enumerate(promoted): parent.insert(index + offset, child) if promoted: promoted[-1].tail = (promoted[-1].tail or "") + (wrapper_tail or "") else: _append_tail(parent, index, wrapper_tail) return True def _strip_import_attributes(element: ET.Element, stats: ProjectionStats) -> None: for name in list(element.attrib): if name not in IMPORT_SOURCE_ATTRIBUTES: continue stats.source_attributes[name] += 1 del element.attrib[name] def _project_subtree(parent: ET.Element, stats: ProjectionStats) -> None: for child in list(parent): part = child.get("data-pptx-part") tag = _local_name(child.tag) if tag == "metadata" and part == "txbody": stats.txbody_metadata += 1 _remove_child(parent, child) continue if _is_hidden_geometry_carrier(child): stats.hidden_geometry_carriers += 1 _remove_child(parent, child) continue _project_subtree(child, stats) _strip_import_attributes(child, stats) if part == "geometry-preview": child.attrib.pop("data-pptx-part", None) if _unwrap_preview(parent, child): stats.geometry_preview_wrappers += 1 elif part == "geometry-detail": child.attrib.pop("data-pptx-part", None) stats.geometry_detail_markers += 1 def _rewrite_asset_reference(value: str, source_dir: Path, output_dir: Path) -> str: if not value or value.startswith("#"): return value parsed = urlsplit(value) if parsed.scheme or parsed.netloc or not parsed.path: return value resolved = (source_dir / parsed.path).resolve() try: relative = os.path.relpath(resolved, output_dir).replace(os.sep, "/") except ValueError: relative = resolved.as_uri() return urlunsplit(("", "", relative, parsed.query, parsed.fragment)) def _rewrite_asset_references( root: ET.Element, source_dir: Path, output_dir: Path, stats: ProjectionStats, ) -> None: for element in root.iter(): for name in ("href", f"{{{XLINK_NS}}}href"): current = element.get(name) if current is None: continue rewritten = _rewrite_asset_reference(current, source_dir, output_dir) if rewritten != current: element.set(name, rewritten) stats.asset_references_rewritten += 1 def _render_projection(source: Path, output: Path) -> tuple[ProjectionReport, bytes]: """Build one projection in memory without changing source or destination.""" original = source.read_bytes() parser = ET.XMLParser( target=ET.TreeBuilder(insert_comments=True, insert_pis=True), ) root = ET.fromstring(original, parser=parser) if _local_name(root.tag) != "svg": raise ValueError(f"Root element is not : {source}") source_references = _stamp_source_references(root) stats = ProjectionStats() _project_subtree(root, stats) _strip_import_attributes(root, stats) _rewrite_asset_references(root, source.parent, output.parent, stats) stats.coordinate_attributes_compacted = compact_svg_tree( root, ).changed_attributes _index_initial_authoring_references(root, source_references) projected = ET.tostring(root, encoding="utf-8", xml_declaration=False) if not projected.endswith(b"\n"): projected += b"\n" report = ProjectionReport( source=source, output=output, original_bytes=len(original), projected_bytes=len(projected), stats=stats, source_sha256=hashlib.sha256(original).hexdigest(), initial_authoring_sha256=hashlib.sha256(projected).hexdigest(), source_references=source_references, ) return report, projected def _portable_path(path: Path, base: Path) -> str: try: return os.path.relpath(path, base).replace(os.sep, "/") except ValueError: return path.resolve().as_uri() def _authoring_manifest_bytes( reports: list[ProjectionReport], source_root: Path, output_dir: Path, projection_kind: str, ) -> bytes: documents = [] for report in sorted(reports, key=lambda item: item.output.as_posix()): documents.append({ "source": report.source.relative_to(source_root).as_posix(), "authoring": report.output.relative_to(output_dir).as_posix(), "source_sha256": report.source_sha256, "initial_authoring_sha256": report.initial_authoring_sha256, "source_refs": { reference.source_ref: reference.as_dict() for reference in sorted( report.source_references, key=lambda item: item.source_ref, ) }, }) payload = { "schema": AUTHORING_SCHEMA, "projection_kind": projection_kind, "source_root": _portable_path(source_root, output_dir), "authoring_root": ".", "source_ref_attribute": SOURCE_REF_ATTRIBUTE, "file_count": len(documents), "source_ref_count": sum(len(report.source_references) for report in reports), "documents": documents, } return ( json.dumps(payload, ensure_ascii=False, indent=2) + "\n" ).encode("utf-8") def _authoring_document_kind(path: Path) -> str: name = path.name if name.startswith("master_"): return "master" if name.startswith("layout_"): return "layout" if name.startswith("slide_"): return "slide" return "generic" def _authoring_summary_document( path: Path, relative_name: str, ) -> dict[str, object]: try: root = ET.parse(path).getroot() except (OSError, ET.ParseError) as exc: raise ValueError(f"Cannot summarize authoring SVG {path}: {exc}") from exc if _local_name(root.tag) != "svg": raise ValueError(f"Authoring document root is not : {path}") elements = list(root.iter()) icon_references = sorted({ icon_name for element in elements if (icon_name := element.get("data-icon")) }) text_elements = [ element for element in elements if _local_name(element.tag) == "text" ] return { "file": relative_name, "kind": _authoring_document_kind(path), "bytes": path.stat().st_size, "viewBox": root.get("viewBox"), "elements": len(elements), "top_level_elements": len(root), "drawables": sum( _local_name(element.tag) in _DRAWABLE_TAGS for element in elements ), "text_elements": len(text_elements), "text_characters": sum( len("".join(element.itertext())) for element in text_elements ), "images": sum( _local_name(element.tag) == "image" for element in elements ), "icon_uses": sum( element.get("data-icon") is not None for element in elements ), "icon_refs": icon_references, "placeholders": sum( element.get("data-pptx-placeholder") is not None for element in elements ), "inline_source_refs": sum( element.get(SOURCE_REF_ATTRIBUTE) is not None for element in elements ), } def _load_authoring_summary_manifest( authoring_dir: Path, ) -> tuple[dict[str, object], list[str]]: manifest_path = authoring_dir / AUTHORING_MANIFEST_NAME try: manifest = json.loads(manifest_path.read_text(encoding="utf-8")) except FileNotFoundError as exc: raise ValueError( f"Authoring manifest not found: {manifest_path}" ) from exc except (OSError, json.JSONDecodeError) as exc: raise ValueError( f"Cannot decode authoring manifest {manifest_path}: {exc}" ) from exc if not isinstance(manifest, dict) or manifest.get("schema") != AUTHORING_SCHEMA: raise ValueError( f"Unsupported authoring manifest schema in {manifest_path}" ) documents = manifest.get("documents") if not isinstance(documents, list): raise ValueError( f"Authoring manifest documents must be an array: {manifest_path}" ) names: list[str] = [] for index, document in enumerate(documents): if not isinstance(document, dict): raise ValueError( f"Authoring manifest documents[{index}] must be an object" ) name = document.get("authoring") relative = Path(name) if isinstance(name, str) else Path() if ( not isinstance(name, str) or not name or relative.is_absolute() or any(part in {"", ".", ".."} for part in relative.parts) or relative.suffix.lower() != ".svg" ): raise ValueError( f"Authoring manifest documents[{index}].authoring is invalid" ) names.append(name) if len(names) != len(set(names)): raise ValueError("Authoring manifest contains duplicate document names") actual_names = sorted( path.relative_to(authoring_dir).as_posix() for path in authoring_dir.rglob("*.svg") if path.is_file() ) if sorted(names) != actual_names: raise ValueError( "Authoring manifest/file roster differs while building summary" ) return manifest, sorted(names) def _authoring_summary_bytes(authoring_dir: Path) -> bytes: manifest, document_names = _load_authoring_summary_manifest(authoring_dir) documents = [ _authoring_summary_document(authoring_dir / name, name) for name in document_names ] total_icon_assets = { icon_name for document in documents for icon_name in document["icon_refs"] } totals = { "svg_bytes": sum(int(document["bytes"]) for document in documents), "elements": sum(int(document["elements"]) for document in documents), "top_level_elements": sum( int(document["top_level_elements"]) for document in documents ), "drawables": sum(int(document["drawables"]) for document in documents), "text_elements": sum( int(document["text_elements"]) for document in documents ), "text_characters": sum( int(document["text_characters"]) for document in documents ), "images": sum(int(document["images"]) for document in documents), "icon_uses": sum(int(document["icon_uses"]) for document in documents), "unique_icon_assets": len(total_icon_assets), "placeholders": sum( int(document["placeholders"]) for document in documents ), "inline_source_refs": sum( int(document["inline_source_refs"]) for document in documents ), "machine_source_refs": manifest.get("source_ref_count"), } payload = { "schema": AUTHORING_SUMMARY_SCHEMA, "projection_kind": manifest.get("projection_kind"), "authoring_root": ".", "machine_manifest": AUTHORING_MANIFEST_NAME, "machine_manifest_policy": "tool-only; do not load into model context", "file_count": len(documents), "totals": totals, "documents": documents, } return ( json.dumps(payload, ensure_ascii=False, indent=2) + "\n" ).encode("utf-8") def write_authoring_summary(authoring_dir: Path) -> Path: """Regenerate the model-readable summary from the current authoring SVGs.""" authoring_dir = Path(authoring_dir).resolve() if not authoring_dir.is_dir(): raise ValueError(f"Authoring directory not found: {authoring_dir}") payload = _authoring_summary_bytes(authoring_dir) summary_path = authoring_dir / AUTHORING_SUMMARY_NAME with tempfile.NamedTemporaryFile( prefix=f".{AUTHORING_SUMMARY_NAME}.", suffix=".tmp", dir=authoring_dir, delete=False, ) as handle: temporary_path = Path(handle.name) handle.write(payload) try: temporary_path.chmod(0o644) temporary_path.replace(summary_path) except OSError: temporary_path.unlink(missing_ok=True) raise return summary_path def _nearest_existing_directory(path: Path) -> Path: candidate = path while not os.path.lexists(candidate): parent = candidate.parent if parent == candidate: break candidate = parent if not candidate.is_dir(): raise NotADirectoryError(f"Output parent is not a directory: {candidate}") return candidate def _ensure_directory(path: Path, created: list[Path]) -> None: missing: list[Path] = [] candidate = path while not candidate.exists(): if os.path.lexists(candidate): raise NotADirectoryError(f"Output parent is not a directory: {candidate}") missing.append(candidate) parent = candidate.parent if parent == candidate: raise NotADirectoryError(f"Cannot resolve output parent: {path}") candidate = parent if not candidate.is_dir(): raise NotADirectoryError(f"Output parent is not a directory: {candidate}") for directory in reversed(missing): directory.mkdir() created.append(directory) def _remove_created_directories(created: list[Path]) -> list[str]: errors: list[str] = [] for directory in reversed(created): try: directory.rmdir() except FileNotFoundError: continue except OSError as exc: errors.append(f"could not remove {directory}: {exc}") return errors def _rollback_published_files( published: list[tuple[Path, Path | None]], created: list[Path], ) -> list[str]: errors: list[str] = [] for target, backup in reversed(published): try: if backup is None: target.unlink(missing_ok=True) else: backup.replace(target) except OSError as exc: errors.append(f"could not restore {target}: {exc}") errors.extend(_remove_created_directories(created)) return errors def _publish_existing_directory( staged: list[tuple[Path, Path]], staging_root: Path, *, force: bool, ) -> None: backup_root = staging_root / "previous" backups: dict[Path, Path | None] = {} for index, (target, _) in enumerate(staged): if not os.path.lexists(target): backups[target] = None continue if not force: raise FileExistsError(f"Output file already exists: {target}") if target.is_dir() and not target.is_symlink(): raise IsADirectoryError(f"Output target is a directory: {target}") backup = backup_root / f"{index:06d}.bak" backup.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(target, backup, follow_symlinks=False) backups[target] = backup created: list[Path] = [] published: list[tuple[Path, Path | None]] = [] try: for target, _ in staged: _ensure_directory(target.parent, created) staging_device = staging_root.stat().st_dev for target, _ in staged: if target.parent.stat().st_dev != staging_device: raise OSError( f"Cannot atomically publish across filesystems: {target}" ) if backups[target] is None and os.path.lexists(target): raise FileExistsError( f"Output appeared while projections were staged: {target}" ) for target, staged_file in staged: staged_file.replace(target) published.append((target, backups[target])) except OSError as exc: rollback_errors = _rollback_published_files(published, created) if rollback_errors: details = "; ".join(rollback_errors) raise RuntimeError( f"Batch publish failed ({exc}); rollback was incomplete: {details}" ) from exc raise def project_svg_batch( mapping: list[tuple[Path, Path]], source_root: Path, output_dir: Path, *, force: bool, projection_kind: str, ) -> list[ProjectionReport]: """Build and publish one complete authoring bundle transactionally.""" rendered = [_render_projection(source, output) for source, output in mapping] staging_parent = _nearest_existing_directory(output_dir.parent) with tempfile.TemporaryDirectory( prefix=".svg-authoring-view-", dir=staging_parent, ) as temporary: staging_root = Path(temporary) new_root = staging_root / "projected" staged: list[tuple[Path, Path]] = [] for report, projected in rendered: relative = report.output.relative_to(output_dir) staged_file = new_root / relative staged_file.parent.mkdir(parents=True, exist_ok=True) staged_file.write_bytes(projected) staged.append((report.output, staged_file)) manifest_path = output_dir / AUTHORING_MANIFEST_NAME staged_manifest = new_root / AUTHORING_MANIFEST_NAME staged_manifest.write_bytes( _authoring_manifest_bytes( [report for report, _ in rendered], source_root, output_dir, projection_kind, ) ) staged.append((manifest_path, staged_manifest)) staged_summary = write_authoring_summary(new_root) staged.append( ( output_dir / AUTHORING_SUMMARY_NAME, staged_summary, ) ) if not output_dir.exists(): created: list[Path] = [] try: _ensure_directory(output_dir.parent, created) if os.path.lexists(output_dir): raise FileExistsError( f"Output directory appeared while projections were staged: {output_dir}" ) if output_dir.parent.stat().st_dev != staging_root.stat().st_dev: raise OSError( f"Cannot atomically publish across filesystems: {output_dir}" ) new_root.replace(output_dir) except OSError as exc: cleanup_errors = _remove_created_directories(created) if cleanup_errors: details = "; ".join(cleanup_errors) raise RuntimeError( f"Batch publish failed ({exc}); cleanup was incomplete: {details}" ) from exc raise else: _publish_existing_directory( staged, staging_root, force=force, ) return [report for report, _ in rendered] def _is_within(path: Path, parent: Path) -> bool: try: path.relative_to(parent) except ValueError: return False return True def _source_mapping(input_path: Path, output_dir: Path) -> list[tuple[Path, Path]]: if input_path.is_file(): if input_path.suffix.lower() != ".svg": raise ValueError(f"Input file must use the .svg extension: {input_path}") return [(input_path, output_dir / input_path.name)] sources = sorted( path for path in input_path.rglob("*") if path.is_file() and path.suffix.lower() == ".svg" ) if not sources: raise ValueError(f"No SVG files found under: {input_path}") return [(source, output_dir / source.relative_to(input_path)) for source in sources] def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description=( "Create lightweight editable IR bundles from PPTX-imported SVG files." ), formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument("input", type=Path, help="SVG file or directory to project") parser.add_argument( "-o", "--output-dir", type=Path, help="Explicit destination directory for projected SVG copies", ) parser.add_argument( "--refresh-summary", action="store_true", help=( "Regenerate authoring_summary.json for an existing authoring " "bundle; input must be that bundle directory and -o is omitted" ), ) parser.add_argument( "--force", action="store_true", help=( "Replace authoring files/manifest that already exist " "(never changes source files)" ), ) parser.add_argument( "--projection-kind", choices=("layered", "flat", "generic"), default="generic", help="Record the IR representation kind in bundle metadata", ) return parser def main(argv: Optional[list[str]] = None) -> int: parser = build_parser() args = parser.parse_args(argv) input_path = args.input.resolve() if not input_path.exists(): print(f"Error: input does not exist: {input_path}", file=sys.stderr) return 1 if args.refresh_summary: if args.output_dir is not None: print( "Error: --refresh-summary does not accept -o/--output-dir", file=sys.stderr, ) return 1 try: summary_path = write_authoring_summary(input_path) except (OSError, ValueError) as exc: print(f"Error: {exc}", file=sys.stderr) return 1 print(json.dumps({ "authoring_dir": str(input_path), "summary": str(summary_path), "summary_bytes": summary_path.stat().st_size, }, ensure_ascii=False, indent=2)) return 0 if args.output_dir is None: parser.error("-o/--output-dir is required unless --refresh-summary is used") output_dir = args.output_dir.resolve() if output_dir.exists() and not output_dir.is_dir(): print(f"Error: output path is not a directory: {output_dir}", file=sys.stderr) return 1 if input_path.is_dir() and _is_within(output_dir, input_path): print("Error: output directory must not be inside the input directory", file=sys.stderr) return 1 try: mapping = _source_mapping(input_path, output_dir) except ValueError as exc: print(f"Error: {exc}", file=sys.stderr) return 1 same_file = [source for source, target in mapping if source.resolve() == target.resolve()] if same_file: print(f"Error: output would overwrite source SVG: {same_file[0]}", file=sys.stderr) return 1 collisions = [target for _, target in mapping if os.path.lexists(target)] manifest_path = output_dir / AUTHORING_MANIFEST_NAME if os.path.lexists(manifest_path): collisions.append(manifest_path) summary_path = output_dir / AUTHORING_SUMMARY_NAME if os.path.lexists(summary_path): collisions.append(summary_path) if collisions and not args.force: print( f"Error: {len(collisions)} output file(s) already exist; " "use --force to replace the authoring bundle. " f"First collision: {collisions[0]}", file=sys.stderr, ) return 1 reports: list[ProjectionReport] = [] try: source_root = input_path if input_path.is_dir() else input_path.parent reports = project_svg_batch( mapping, source_root, output_dir, force=args.force, projection_kind=args.projection_kind, ) except (ET.ParseError, OSError, RuntimeError, ValueError) as exc: print(f"Error: {exc}", file=sys.stderr) return 1 total_stats = ProjectionStats() original_bytes = 0 projected_bytes = 0 for report in reports: original_bytes += report.original_bytes projected_bytes += report.projected_bytes total_stats.merge(report.stats) bytes_saved = original_bytes - projected_bytes reduction = (bytes_saved / original_bytes * 100) if original_bytes else 0.0 result = { "input": str(input_path), "output_dir": str(output_dir), "manifest": str(output_dir / AUTHORING_MANIFEST_NAME), "summary": str(output_dir / AUTHORING_SUMMARY_NAME), "projection_kind": args.projection_kind, "file_count": len(reports), "files": [report.as_dict() for report in reports], "totals": { "original_bytes": original_bytes, "projected_bytes": projected_bytes, "bytes_saved": bytes_saved, "reduction_percent": round(reduction, 2), "removed": total_stats.as_dict(), }, } print(json.dumps(result, ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": raise SystemExit(main())