"""Top-level orchestrator for PPTX -> SVG conversion. Public API: convert_pptx_to_svg(pptx_path, output_dir, options). Composes the per-slide pipeline: OoxmlPackage -> shape_walker.walk_sp_tree -> per-shape dispatch (prstgeom / txbody / pic / ...) -> assembled SVG text + extracted media files Stages B-F will fill in the per-shape dispatch. For Stage A this entry just loads the package and reports basic per-slide structure to verify wiring. """ from __future__ import annotations import json import os import re import shutil import tempfile from collections.abc import Callable from dataclasses import dataclass, field from html import unescape from pathlib import Path, PurePosixPath from urllib.parse import unquote, urlsplit from .color_resolver import ColorPalette from .emu_units import NS from .import_diagnostics import ImportDiagnostic, append_diagnostic from .ooxml_loader import ( OoxmlPackage, PartRef, SlideRef, part_show_master_sp, ) from .slide_to_svg import assemble_part_solo, assemble_slide _CJK_THEME_SCRIPTS = frozenset({"Hans", "Hant", "Jpan", "Hang"}) _MANAGED_PRIMARY_SVG_RE = re.compile( r"(?:slide_\d+|master_\d+_[A-Za-z0-9_-]+|layout_\d+_[A-Za-z0-9_-]+)\.svg" ) _MANAGED_FLAT_SVG_RE = re.compile(r"slide_\d+\.svg") _SVG_HREF_RE = re.compile( r"\b(?:href|xlink:href)\s*=\s*[\"']([^\"']+)[\"']" ) def _validate_media_subdir(value: str) -> None: """Reject media output paths that can escape the conversion workspace.""" path = Path(value) if path.drive or path.anchor or path.is_absolute() or ".." in path.parts: raise ValueError( f"media_subdir must stay within the output workspace: {value!r}" ) def _validate_media_filename(filename: str) -> None: """Require one media basename so asset maps cannot redirect writes.""" path = Path(filename) if ( not filename or filename in {".", ".."} or path.drive or path.anchor or path.name != filename or "/" in filename or "\\" in filename ): raise ValueError(f"Media filename must be a basename: {filename!r}") def _extract_theme_info( theme: PartRef, palette: ColorPalette, ) -> tuple[dict[str, str], dict[str, str]]: from .color_resolver import find_color_elem, resolve_color colors: dict[str, str] = {} fonts: dict[str, str] = {} scheme = theme.xml.find(".//a:clrScheme", NS) if scheme is not None: for child in list(scheme): if not isinstance(child.tag, str): continue name = child.tag.split("}", 1)[-1] try: color_elem = find_color_elem(child) hex_, _ = resolve_color(color_elem, palette) except ValueError as exc: if palette.strict: raise palette._diagnose( "theme-summary-color-omitted", str(exc), "omit only this malformed theme-summary color", ) continue if hex_: colors[name] = hex_ font_scheme = theme.xml.find(".//a:fontScheme", NS) if font_scheme is not None: for slot in ("majorFont", "minorFont"): fnt = font_scheme.find(f"a:{slot}", NS) if fnt is None: continue role_prefix = "major" if slot == "majorFont" else "minor" latin = fnt.find("a:latin", NS) if latin is not None and latin.attrib.get("typeface"): fonts[f"{role_prefix}Latin"] = latin.attrib["typeface"] ea = fnt.find("a:ea", NS) if ea is not None and ea.attrib.get("typeface"): fonts[f"{role_prefix}EastAsia"] = ea.attrib["typeface"] cs = fnt.find("a:cs", NS) if cs is not None and cs.attrib.get("typeface"): fonts[f"{role_prefix}ComplexScript"] = cs.attrib["typeface"] for supplemental in fnt.findall("a:font", NS): script = supplemental.attrib.get("script", "") typeface = supplemental.attrib.get("typeface", "") if script in _CJK_THEME_SCRIPTS and typeface: fonts[f"{role_prefix}Script{script}"] = typeface return colors, fonts @dataclass class ConvertOptions: """Convert behavior knobs. media_subdir: where to write media files relative to output_dir. SVG image href will use './/'. embed_images: when True, base64-encode images inline instead of writing files. Default False (matches svg_to_pptx default of external images). keep_hidden: include shapes marked hidden="1". Default False. inheritance_mode: how to render master/layout shapes per slide SVG. - "both" (default): emit both views — layered under svg/ for template designers (master/layout/slide as separate files) and flat under svg-flat/ for previewers (each slide self-contained). Costs roughly 1.3-1.5× converter time and ~1.6-2× disk vs. either single mode. - "layered": skip inherited shapes inside the slide. The orchestrator renders every master and layout to its own SVG, plus svg/inheritance.json describing the reuse graph. Optimised for template authors who need to see "what is shared vs. unique". - "flat": inline the inherited shapes visible under the source ``showMasterSp`` flags. Used by svg_to_pptx round-trip and any caller that wants self-contained slides (preview pages, screenshot pipelines). strict: stop on the first unsupported or malformed source construct. Default False keeps usable content and records structured diagnostics. """ media_subdir: str = "assets" embed_images: bool = False keep_hidden: bool = False inheritance_mode: str = "both" asset_name_map: dict[str, str] = field(default_factory=dict) strict: bool = False @dataclass class PartArtifact: """Result of converting a master or layout part to SVG (layered mode only).""" role: str # "master" | "layout" part_path: str # OOXML part path, e.g. "ppt/slideLayouts/slideLayout3.xml" filename: str # output svg filename, e.g. "layout_03_title.xml.svg" svg: str media_files: dict[str, bytes] = field(default_factory=dict) parent_master_part_path: str | None = None theme_part_path: str | None = None show_master_shapes: bool = True @dataclass class SlideArtifact: """Result of converting a single slide.""" index: int # 1-based svg: str media_files: dict[str, bytes] = field(default_factory=dict) layout_part_path: str | None = None master_part_path: str | None = None show_inherited_shapes: bool = True @dataclass class ConvertResult: """Result of converting an entire .pptx. ``slides`` holds the layered/primary view (or, in pure flat mode, the flat view). ``flat_slides`` is populated only in ``"both"`` mode and contains self-contained renderings of every slide; callers that don't care about the flat view can ignore it. """ slides: list[SlideArtifact] = field(default_factory=list) canvas_px: tuple[float, float] = (1280.0, 720.0) theme_colors: dict[str, str] = field(default_factory=dict) theme_fonts: dict[str, str] = field(default_factory=dict) layouts: list[PartArtifact] = field(default_factory=list) masters: list[PartArtifact] = field(default_factory=list) flat_slides: list[SlideArtifact] = field(default_factory=list) master_themes: dict[str, dict[str, object]] = field(default_factory=dict) diagnostics: list[ImportDiagnostic] = field(default_factory=list) source_file: str = "" strict: bool = False def _palette_diagnostic_sink( result: ConvertResult, *, part_path: str, slide_index: int | None = None, ) -> Callable[[str, str, str], None]: """Build a package-level diagnostic sink for palette initialization.""" def _record(code: str, message: str, fallback: str) -> None: append_diagnostic( result.diagnostics, ImportDiagnostic( code=code, message=message, fallback=fallback, part_path=part_path, slide_index=slide_index, ), ) return _record def _make_palette( master: PartRef | None, theme: PartRef | None, options: ConvertOptions, result: ConvertResult, *, part_path: str, slide_index: int | None = None, ) -> ColorPalette: """Create one strict or tolerant palette with structured diagnostics.""" return ColorPalette( master, theme, strict=options.strict, diagnostic_sink=_palette_diagnostic_sink( result, part_path=part_path, slide_index=slide_index, ), ) # --------------------------------------------------------------------------- # Entry # --------------------------------------------------------------------------- def convert_pptx_to_svg( pptx_path: Path, output_dir: Path | None = None, options: ConvertOptions | None = None, ) -> ConvertResult: """Convert a .pptx file to one SVG per slide. Args: pptx_path: Source .pptx file. output_dir: When given, write svg/.svg + media files there. When None, files are not written; callers can read SlideArtifact.svg. options: ConvertOptions; defaults to ConvertOptions(). Returns: ConvertResult with per-slide SVG strings and resolved theme info. """ options = options or ConvertOptions() if options.inheritance_mode not in {"flat", "layered", "both"}: raise ValueError( f"inheritance_mode must be 'flat', 'layered', or 'both', " f"got {options.inheritance_mode!r}" ) if not options.embed_images: _validate_media_subdir(options.media_subdir) emit_layered = options.inheritance_mode in {"layered", "both"} emit_flat = options.inheritance_mode in {"flat", "both"} result = ConvertResult( source_file=pptx_path.name, strict=options.strict, ) with OoxmlPackage(pptx_path) as pkg: result.canvas_px = pkg.slide_size_px # Default theme summary is kept for compatibility; conversion itself # resolves palette/fonts per slide master. first_slide = pkg.get_slide(1) default_master = first_slide.master if first_slide else None default_theme = pkg.resolve_theme(default_master) palette = _make_palette( default_master, default_theme, options, result, part_path=default_theme.path if default_theme is not None else "", ) if default_theme is not None: result.theme_colors, result.theme_fonts = _extract_theme_info(default_theme, palette) for master in pkg.iter_all_masters(): theme = pkg.resolve_theme(master) or default_theme pal = _make_palette( master, theme, options, result, part_path=master.path, ) colors, fonts = _extract_theme_info(theme, pal) if theme is not None else ({}, {}) result.master_themes[master.path] = { "themePath": theme.path if theme is not None else None, "colors": colors, "fonts": fonts, } # Per-slide conversion. The primary view is layered when emitted # (template designers care most about that one); the flat view is # rendered alongside when needed. primary_mode = "layered" if emit_layered else "flat" for slide in pkg.iter_slides(): slide_theme = pkg.resolve_theme(slide.master) or default_theme slide_palette = _make_palette( slide.master, slide_theme, options, result, part_path=slide.part.path, slide_index=slide.index, ) _colors, slide_fonts = _extract_theme_info(slide_theme, slide_palette) if slide_theme is not None else ({}, result.theme_fonts) artifact = _convert_slide( pkg, slide, slide_palette, options, result.diagnostics, slide_fonts, inheritance_mode=primary_mode, ) result.slides.append(artifact) if emit_layered and emit_flat: for slide in pkg.iter_slides(): slide_theme = pkg.resolve_theme(slide.master) or default_theme slide_palette = _make_palette( slide.master, slide_theme, options, result, part_path=slide.part.path, slide_index=slide.index, ) _colors, slide_fonts = _extract_theme_info(slide_theme, slide_palette) if slide_theme is not None else ({}, result.theme_fonts) artifact = _convert_slide( pkg, slide, slide_palette, options, result.diagnostics, slide_fonts, inheritance_mode="flat", ) result.flat_slides.append(artifact) # Layered mode: also render each master / layout once. if emit_layered: _convert_inheritance_parts(pkg, default_theme, options, result) if output_dir is not None: _write_artifacts(output_dir, result, options) return result def _convert_slide( pkg: OoxmlPackage, slide: SlideRef, palette: ColorPalette, options: ConvertOptions, diagnostics: list[ImportDiagnostic], theme_fonts: dict[str, str] | None = None, *, inheritance_mode: str | None = None, ) -> SlideArtifact: """Convert a single slide via the full shape pipeline. ``inheritance_mode`` overrides ``options.inheritance_mode`` so the orchestrator can render the same slide twice (once layered, once flat) when the user asked for ``"both"``. Pass ``"flat"`` or ``"layered"``; ``None`` falls back to ``options.inheritance_mode`` (used by direct callers that want a single mode). """ mode = inheritance_mode or options.inheritance_mode if mode == "both": mode = "layered" # primary view in both-mode show_inherited_shapes = part_show_master_sp(slide.part) svg, media = assemble_slide( pkg, slide, palette, theme_fonts=theme_fonts, media_subdir=options.media_subdir, embed_images=options.embed_images, keep_hidden=options.keep_hidden, inheritance_mode=mode, asset_name_map=options.asset_name_map, strict=options.strict, diagnostics=diagnostics, ) return SlideArtifact( index=slide.index, svg=svg, media_files=media, layout_part_path=slide.layout.path if slide.layout else None, master_part_path=slide.master.path if slide.master else None, show_inherited_shapes=show_inherited_shapes, ) def _convert_inheritance_parts( pkg: OoxmlPackage, default_theme: PartRef | None, options: ConvertOptions, result: ConvertResult, ) -> None: """Render every master and layout in the deck to its own SVG (layered mode). We deliberately render *all* masters / layouts, not only the ones a slide references. Multi-style template packages routinely ship more design surfaces than the embedded sample slides exercise, and dropping unused ones discards the bulk of the template's design intent. """ # Collect unique parts in document order so output filenames are # deterministic for a given .pptx. seen_masters: dict[str, PartRef] = {} for master in pkg.iter_all_masters(): if master.path not in seen_masters: seen_masters[master.path] = master layouts_with_parent: list[tuple[PartRef, PartRef]] = [] seen_layout_paths: set[str] = set() for layout, parent_master in pkg.iter_all_layouts_with_parent(): if layout.path in seen_layout_paths: continue seen_layout_paths.add(layout.path) layouts_with_parent.append((layout, parent_master)) for seq, part in enumerate(seen_masters.values(), start=1): theme = pkg.resolve_theme(part) or default_theme palette = _make_palette( part, theme, options, result, part_path=part.path, ) _colors, fonts = _extract_theme_info(theme, palette) if theme is not None else ({}, result.theme_fonts) result.masters.append(_render_part( pkg, part, palette, options, result.diagnostics, fonts, role="master", seq=seq, theme_part=theme, )) for seq, (layout, parent_master) in enumerate(layouts_with_parent, start=1): theme = pkg.resolve_theme(parent_master) or default_theme palette = _make_palette( parent_master, theme, options, result, part_path=layout.path, ) _colors, fonts = _extract_theme_info(theme, palette) if theme is not None else ({}, result.theme_fonts) result.layouts.append(_render_part( pkg, layout, palette, options, result.diagnostics, fonts, role="layout", seq=seq, parent_master=parent_master, theme_part=theme, )) def _render_part( pkg: OoxmlPackage, part: PartRef, palette: ColorPalette, options: ConvertOptions, diagnostics: list[ImportDiagnostic], theme_fonts: dict[str, str], *, role: str, seq: int, parent_master: PartRef | None = None, theme_part: PartRef | None = None, ) -> PartArtifact: """Render a master/layout part, returning a PartArtifact with output filename.""" svg, media = assemble_part_solo( pkg, part, palette, role=role, parent_master=parent_master, theme_fonts=theme_fonts, media_subdir=options.media_subdir, embed_images=options.embed_images, keep_hidden=options.keep_hidden, asset_name_map=options.asset_name_map, strict=options.strict, diagnostics=diagnostics, ) stem = PurePosixPath(part.path).stem # e.g. "slideLayout3" safe_stem = re.sub(r"[^A-Za-z0-9_-]+", "_", stem).strip("_") or role filename = f"{role}_{seq:02d}_{safe_stem}.svg" return PartArtifact( role=role, part_path=part.path, filename=filename, svg=svg, media_files=media, parent_master_part_path=parent_master.path if parent_master is not None else None, theme_part_path=theme_part.path if theme_part is not None else None, show_master_shapes=( part_show_master_sp(part) if role == "layout" else True ), ) def _path_lexists(path: Path) -> bool: """Return whether a path or symlink exists without following the symlink.""" return path.exists() or path.is_symlink() def _managed_svg_paths(output_dir: Path) -> list[Path]: """Return converter-owned SVG files without traversing user directories.""" managed: list[Path] = [] for dirname, filename_re in ( ("svg", _MANAGED_PRIMARY_SVG_RE), ("svg-flat", _MANAGED_FLAT_SVG_RE), ): svg_dir = output_dir / dirname if svg_dir.is_symlink(): managed.append(svg_dir) continue if not svg_dir.is_dir(): continue managed.extend( path for path in svg_dir.iterdir() if filename_re.fullmatch(path.name) and (path.is_file() or path.is_symlink()) ) inheritance = svg_dir / "inheritance.json" if dirname == "svg" and _path_lexists(inheritance): managed.append(inheritance) return managed def _referenced_local_paths( output_dir: Path, svg_paths: list[Path], ) -> set[Path]: """Resolve local media referenced by converter-owned SVGs.""" referenced: set[Path] = set() output_abs = output_dir.absolute() for svg_path in svg_paths: if svg_path.is_symlink() or not svg_path.is_file(): continue try: svg_text = svg_path.read_text(encoding="utf-8") except (OSError, UnicodeError): continue for raw_href in _SVG_HREF_RE.findall(svg_text): href = unescape(raw_href) parsed = urlsplit(href) if parsed.scheme or parsed.netloc or not parsed.path: continue href_path = unquote(parsed.path) if Path(href_path).is_absolute(): continue target = Path(os.path.normpath(str(svg_path.parent / href_path))) try: relative = target.absolute().relative_to(output_abs) except ValueError: continue if relative.parts: referenced.add(relative) return referenced def _validated_relative_paths(paths: set[str | Path]) -> set[Path]: """Normalize caller-supplied managed paths and reject output escapes.""" normalized: set[Path] = set() for value in paths: path = Path(value) if ( path.drive or path.anchor or path.is_absolute() or not path.parts or ".." in path.parts ): raise ValueError(f"Managed artifact path must stay relative: {value}") normalized.add(path) return normalized def _reject_symlink_ancestors( root: Path, relative_paths: set[Path], ) -> None: """Reject managed paths that would traverse a preserved user symlink.""" for relative in relative_paths: current = root for component in relative.parts[:-1]: current /= component if current.is_symlink(): raise RuntimeError( "Managed artifact path crosses an unmanaged symlink: " f"{relative}" ) def _remove_managed_paths(candidate_dir: Path, relative_paths: set[Path]) -> None: """Remove only the previous converter roster from a candidate workspace.""" _reject_symlink_ancestors(candidate_dir, relative_paths) parents: set[Path] = set() for relative in sorted( relative_paths, key=lambda item: len(item.parts), reverse=True, ): target = candidate_dir / relative if target.is_symlink() or target.is_file(): target.unlink() elif target.is_dir(): raise RuntimeError( "Managed artifact path collides with a preserved directory: " f"{relative}" ) parent = target.parent while parent != candidate_dir: parents.add(parent) parent = parent.parent for parent in sorted(parents, key=lambda item: len(item.parts), reverse=True): if parent.is_symlink() or not parent.is_dir(): continue try: parent.rmdir() except OSError: pass def _overlay_staged_tree(staged_dir: Path, candidate_dir: Path) -> None: """Overlay generated artifacts without overwriting unmanaged user files.""" for source in sorted(staged_dir.rglob("*")): relative = source.relative_to(staged_dir) target = candidate_dir / relative _reject_symlink_ancestors(candidate_dir, {relative}) if source.is_symlink(): raise RuntimeError( f"Generated artifact must not be a symlink: {relative}" ) if source.is_dir(): if ( target.is_symlink() or (_path_lexists(target) and not target.is_dir()) ): raise RuntimeError( f"Generated artifact collides with unmanaged path: {relative}" ) target.mkdir(parents=True, exist_ok=True) continue target.parent.mkdir(parents=True, exist_ok=True) if _path_lexists(target): if target.is_dir() or target.is_symlink(): raise RuntimeError( f"Generated artifact collides with unmanaged path: {relative}" ) if target.read_bytes() != source.read_bytes(): raise RuntimeError( f"Generated artifact collides with unmanaged file: {relative}" ) shutil.copy2(source, target) def publish_staged_workspace( output_dir: Path, staged_dir: Path, *, managed_root_files: set[str | Path] | None = None, managed_relative_paths: set[str | Path] | None = None, ) -> None: """Atomically publish generated artifacts while preserving user files. Converter-owned SVGs, their local media references, and the named managed artifacts are replaced as one roster. Everything else already present in the output directory is copied into the candidate unchanged. """ output_dir = output_dir.absolute() staged_dir = staged_dir.absolute() if ( output_dir == staged_dir or output_dir in staged_dir.parents or staged_dir in output_dir.parents ): raise ValueError( "Staged and output workspaces must not contain one another" ) output_resolved = output_dir.resolve(strict=False) try: Path.cwd().resolve().relative_to(output_resolved) except ValueError: pass else: raise RuntimeError( "Output workspace must not contain the current working directory" ) if not staged_dir.is_dir(): raise ValueError(f"Staged workspace does not exist: {staged_dir}") if ( output_dir.is_symlink() or (_path_lexists(output_dir) and not output_dir.is_dir()) ): raise RuntimeError(f"Output path must be a real directory: {output_dir}") output_dir.parent.mkdir(parents=True, exist_ok=True) transaction_dir = Path(tempfile.mkdtemp( prefix=f".{output_dir.name}.publish-", dir=output_dir.parent, )) candidate_dir = transaction_dir / "candidate" backup_dir = transaction_dir / "previous" preserve_backup = False try: if output_dir.is_dir(): shutil.copytree(output_dir, candidate_dir, symlinks=True) else: candidate_dir.mkdir() managed_svg = _managed_svg_paths(output_dir) relative_paths = { path.relative_to(output_dir) for path in managed_svg } relative_paths.update(_referenced_local_paths(output_dir, managed_svg)) relative_paths.add(Path("conversion-report.json")) relative_paths.update(_validated_relative_paths(managed_root_files or set())) relative_paths.update(_validated_relative_paths(managed_relative_paths or set())) _remove_managed_paths(candidate_dir, relative_paths) _overlay_staged_tree(staged_dir, candidate_dir) if output_dir.is_dir(): try: os.replace(output_dir, backup_dir) os.replace(candidate_dir, output_dir) except BaseException as publish_error: try: if _path_lexists(backup_dir): if _path_lexists(output_dir): failed_output = transaction_dir / "failed-publish" os.replace(output_dir, failed_output) os.replace(backup_dir, output_dir) except BaseException as restore_error: if ( not _path_lexists(backup_dir) and _path_lexists(output_dir) ): raise publish_error preserve_backup = _path_lexists(backup_dir) raise RuntimeError( "Failed to publish the new workspace and restore the " "previous workspace; recovery directory: " f"{transaction_dir}" ) from restore_error raise else: os.replace(candidate_dir, output_dir) finally: if not preserve_backup: shutil.rmtree(transaction_dir, ignore_errors=True) def _write_artifact_tree( output_dir: Path, result: ConvertResult, options: ConvertOptions, ) -> None: """Write a complete converter roster into an empty staging directory. Layout: - ``svg/`` primary view (layered when emitted, otherwise flat) - ``svg-flat/`` self-contained per-slide renders (only in "both" mode) - ``/`` shared image assets, referenced by both views """ output_dir.mkdir(parents=True, exist_ok=True) svg_dir = output_dir / "svg" svg_dir.mkdir(exist_ok=True) media_dir = output_dir / options.media_subdir media_written: dict[str, bytes] = {} def _collect_media(media: dict[str, bytes]) -> None: for filename, blob in media.items(): _validate_media_filename(filename) if filename in media_written: if media_written[filename] != blob: raise RuntimeError( f"Asset filename collision with different bytes: {filename}" ) continue media_written[filename] = blob # Layered mode: write masters and layouts first so they sort ahead of slides. for art in result.masters: (svg_dir / art.filename).write_text(art.svg, encoding="utf-8") _collect_media(art.media_files) for art in result.layouts: (svg_dir / art.filename).write_text(art.svg, encoding="utf-8") _collect_media(art.media_files) # Slides (primary view). for art in result.slides: target = svg_dir / f"slide_{art.index:02d}.svg" target.write_text(art.svg, encoding="utf-8") _collect_media(art.media_files) # Inheritance graph alongside the layered SVGs (only meaningful when we # actually emitted a layered view). if options.inheritance_mode in {"layered", "both"}: _write_inheritance_json(svg_dir, result) # Flat companion view (only when result.flat_slides is populated). if result.flat_slides: flat_dir = output_dir / "svg-flat" flat_dir.mkdir(exist_ok=True) for art in result.flat_slides: target = flat_dir / f"slide_{art.index:02d}.svg" target.write_text(art.svg, encoding="utf-8") _collect_media(art.media_files) _write_conversion_report(output_dir, result) if media_written: media_dir.mkdir(parents=True, exist_ok=True) for filename, blob in media_written.items(): target = media_dir / filename if _path_lexists(target): if ( target.is_symlink() or not target.is_file() or target.read_bytes() != blob ): raise RuntimeError( f"Asset filename collision with different bytes: {filename}" ) continue target.write_bytes(blob) def _write_artifacts( output_dir: Path, result: ConvertResult, options: ConvertOptions, ) -> None: """Stage a complete conversion, then atomically publish its exact roster.""" output_dir = output_dir.absolute() output_dir.parent.mkdir(parents=True, exist_ok=True) staging_root = Path(tempfile.mkdtemp( prefix=f".{output_dir.name}.convert-", dir=output_dir.parent, )) staged_dir = staging_root / "generated" try: _write_artifact_tree(staged_dir, result, options) publish_staged_workspace(output_dir, staged_dir) finally: shutil.rmtree(staging_root, ignore_errors=True) def _write_conversion_report(output_dir: Path, result: ConvertResult) -> None: """Write the user-visible tolerant-import report.""" report = { "schemaVersion": 1, "source": result.source_file, "mode": "strict" if result.strict else "tolerant", "summary": { "slides": len(result.slides), "warnings": len(result.diagnostics), }, "diagnostics": [item.to_dict() for item in result.diagnostics], } (output_dir / "conversion-report.json").write_text( json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) def _write_inheritance_json(svg_dir: Path, result: ConvertResult) -> None: """Record layered parentage plus source-owned shape-visibility booleans.""" layout_by_path = {art.part_path: art.filename for art in result.layouts} master_by_path = {art.part_path: art.filename for art in result.masters} inheritance = { "masters": [ { "file": art.filename, "partPath": art.part_path, "themePath": art.theme_part_path, } for art in result.masters ], "layouts": [ { "file": art.filename, "partPath": art.part_path, "master": master_by_path.get(art.parent_master_part_path or ""), "parentPartPath": art.parent_master_part_path, "themePath": art.theme_part_path, "showMasterShapes": art.show_master_shapes, } for art in result.layouts ], "slides": [ { "file": f"slide_{slide.index:02d}.svg", "index": slide.index, "layout": layout_by_path.get(slide.layout_part_path or ""), "master": master_by_path.get(slide.master_part_path or ""), "showInheritedShapes": slide.show_inherited_shapes, } for slide in result.slides ], } (svg_dir / "inheritance.json").write_text( json.dumps(inheritance, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", )