返回 ppt-master
converter.py
根目录 / skills / ppt-master / scripts / pptx_to_svg / converter.py
1 """Top-level orchestrator for PPTX -> SVG conversion.
2
3 Public API: convert_pptx_to_svg(pptx_path, output_dir, options).
4
5 Composes the per-slide pipeline:
6 OoxmlPackage -> shape_walker.walk_sp_tree
7 -> per-shape dispatch (prstgeom / txbody / pic / ...)
8 -> assembled SVG text + extracted media files
9
10 Stages B-F will fill in the per-shape dispatch. For Stage A this entry just
11 loads the package and reports basic per-slide structure to verify wiring.
12 """
13
14 from __future__ import annotations
15
16 import json
17 import os
18 import re
19 import shutil
20 import tempfile
21 from collections.abc import Callable
22 from dataclasses import dataclass, field
23 from html import unescape
24 from pathlib import Path, PurePosixPath
25 from urllib.parse import unquote, urlsplit
26
27 from svg_to_pptx.animation_config import (
28 validate_animation_config_errors,
29 validate_transition_config,
30 )
31
32 from .animation_import import (
33 AnimationImportError,
34 import_slide_animation,
35 )
36 from .color_resolver import ColorPalette
37 from .emu_units import NS
38 from .import_diagnostics import ImportDiagnostic, append_diagnostic
39 from .ooxml_loader import (
40 OoxmlPackage,
41 PartRef,
42 SlideRef,
43 part_show_master_sp,
44 )
45 from .slide_to_svg import assemble_part_solo, assemble_slide
46 from .transition_import import (
47 TransitionImportError,
48 import_slide_transition,
49 )
50
51
52 _CJK_THEME_SCRIPTS = frozenset({"Hans", "Hant", "Jpan", "Hang"})
53 _MANAGED_PRIMARY_SVG_RE = re.compile(
54 r"(?:slide_\d+|master_\d+_[A-Za-z0-9_-]+|layout_\d+_[A-Za-z0-9_-]+)\.svg"
55 )
56 _MANAGED_FLAT_SVG_RE = re.compile(r"slide_\d+\.svg")
57 _MANAGED_TRANSITION_SOUND_RE = re.compile(
58 r"transition_sound_[0-9a-f]{16}\.wav"
59 )
60 _SVG_HREF_RE = re.compile(
61 r"\b(?:href|xlink:href)\s*=\s*[\"']([^\"']+)[\"']"
62 )
63
64
65 def _validate_media_subdir(value: str) -> None:
66 """Reject media output paths that can escape the conversion workspace."""
67 path = Path(value)
68 if path.drive or path.anchor or path.is_absolute() or ".." in path.parts:
69 raise ValueError(
70 f"media_subdir must stay within the output workspace: {value!r}"
71 )
72
73
74 def _validate_media_filename(filename: str) -> None:
75 """Require one media basename so asset maps cannot redirect writes."""
76 path = Path(filename)
77 if (
78 not filename
79 or filename in {".", ".."}
80 or path.drive
81 or path.anchor
82 or path.name != filename
83 or "/" in filename
84 or "\\" in filename
85 ):
86 raise ValueError(f"Media filename must be a basename: {filename!r}")
87
88
89 def _extract_theme_info(
90 theme: PartRef,
91 palette: ColorPalette,
92 ) -> tuple[dict[str, str], dict[str, str]]:
93 from .color_resolver import find_color_elem, resolve_color
94
95 colors: dict[str, str] = {}
96 fonts: dict[str, str] = {}
97
98 scheme = theme.xml.find(".//a:clrScheme", NS)
99 if scheme is not None:
100 for child in list(scheme):
101 if not isinstance(child.tag, str):
102 continue
103 name = child.tag.split("}", 1)[-1]
104 try:
105 color_elem = find_color_elem(child)
106 hex_, _ = resolve_color(color_elem, palette)
107 except ValueError as exc:
108 if palette.strict:
109 raise
110 palette._diagnose(
111 "theme-summary-color-omitted",
112 str(exc),
113 "omit only this malformed theme-summary color",
114 )
115 continue
116 if hex_:
117 colors[name] = hex_
118
119 font_scheme = theme.xml.find(".//a:fontScheme", NS)
120 if font_scheme is not None:
121 for slot in ("majorFont", "minorFont"):
122 fnt = font_scheme.find(f"a:{slot}", NS)
123 if fnt is None:
124 continue
125 role_prefix = "major" if slot == "majorFont" else "minor"
126 latin = fnt.find("a:latin", NS)
127 if latin is not None and latin.attrib.get("typeface"):
128 fonts[f"{role_prefix}Latin"] = latin.attrib["typeface"]
129 ea = fnt.find("a:ea", NS)
130 if ea is not None and ea.attrib.get("typeface"):
131 fonts[f"{role_prefix}EastAsia"] = ea.attrib["typeface"]
132 cs = fnt.find("a:cs", NS)
133 if cs is not None and cs.attrib.get("typeface"):
134 fonts[f"{role_prefix}ComplexScript"] = cs.attrib["typeface"]
135 for supplemental in fnt.findall("a:font", NS):
136 script = supplemental.attrib.get("script", "")
137 typeface = supplemental.attrib.get("typeface", "")
138 if script in _CJK_THEME_SCRIPTS and typeface:
139 fonts[f"{role_prefix}Script{script}"] = typeface
140
141 return colors, fonts
142
143
144 @dataclass
145 class ConvertOptions:
146 """Convert behavior knobs.
147
148 media_subdir: where to write media files relative to output_dir. SVG image
149 href will use './<media_subdir>/<filename>'.
150 embed_images: when True, base64-encode images inline instead of writing
151 files. Default False (matches svg_to_pptx default of external images).
152 keep_hidden: include shapes marked hidden="1". Default False.
153 inheritance_mode: how to render master/layout shapes per slide SVG.
154 - "both" (default): emit both views — layered under svg/ for template
155 designers (master/layout/slide as separate files) and flat under
156 svg-flat/ for previewers (each slide self-contained). Costs roughly
157 1.3-1.5× converter time and ~1.6-2× disk vs. either single mode.
158 - "layered": skip inherited shapes inside the slide. The orchestrator
159 renders every master and layout to its own SVG, plus
160 svg/inheritance.json describing the reuse graph. Optimised for
161 template authors who need to see "what is shared vs. unique".
162 - "flat": inline the inherited shapes visible under the source
163 ``showMasterSp`` flags. Used by svg_to_pptx round-trip and any caller
164 that wants self-contained slides (preview pages, screenshot pipelines).
165 strict: stop on the first unsupported or malformed source construct.
166 Default False keeps usable content and records structured diagnostics.
167 """
168
169 media_subdir: str = "assets"
170 embed_images: bool = False
171 keep_hidden: bool = False
172 inheritance_mode: str = "both"
173 asset_name_map: dict[str, str] = field(default_factory=dict)
174 strict: bool = False
175
176
177 @dataclass
178 class PartArtifact:
179 """Result of converting a master or layout part to SVG (layered mode only)."""
180
181 role: str # "master" | "layout"
182 part_path: str # OOXML part path, e.g. "ppt/slideLayouts/slideLayout3.xml"
183 filename: str # output svg filename, e.g. "layout_03_title.xml.svg"
184 svg: str
185 media_files: dict[str, bytes] = field(default_factory=dict)
186 parent_master_part_path: str | None = None
187 theme_part_path: str | None = None
188 show_master_shapes: bool = True
189
190
191 @dataclass
192 class SlideArtifact:
193 """Result of converting a single slide."""
194
195 index: int # 1-based
196 svg: str
197 media_files: dict[str, bytes] = field(default_factory=dict)
198 layout_part_path: str | None = None
199 master_part_path: str | None = None
200 show_inherited_shapes: bool = True
201
202
203 @dataclass
204 class ConvertResult:
205 """Result of converting an entire .pptx.
206
207 ``slides`` holds the layered/primary view (or, in pure flat mode, the flat
208 view). ``flat_slides`` is populated only in ``"both"`` mode and contains
209 self-contained renderings of every slide; callers that don't care about
210 the flat view can ignore it.
211 """
212
213 slides: list[SlideArtifact] = field(default_factory=list)
214 canvas_px: tuple[float, float] = (1280.0, 720.0)
215 theme_colors: dict[str, str] = field(default_factory=dict)
216 theme_fonts: dict[str, str] = field(default_factory=dict)
217 layouts: list[PartArtifact] = field(default_factory=list)
218 masters: list[PartArtifact] = field(default_factory=list)
219 flat_slides: list[SlideArtifact] = field(default_factory=list)
220 master_themes: dict[str, dict[str, object]] = field(default_factory=dict)
221 diagnostics: list[ImportDiagnostic] = field(default_factory=list)
222 animation_config: dict[str, object] = field(
223 default_factory=lambda: {
224 "version": 1,
225 "defaults": {
226 "transition": {
227 "effect": "none",
228 "duration": 0.0,
229 },
230 },
231 "slides": {},
232 }
233 )
234 animation_media_files: dict[str, bytes] = field(default_factory=dict)
235 source_file: str = ""
236 strict: bool = False
237
238
239 def _palette_diagnostic_sink(
240 result: ConvertResult,
241 *,
242 part_path: str,
243 slide_index: int | None = None,
244 ) -> Callable[[str, str, str], None]:
245 """Build a package-level diagnostic sink for palette initialization."""
246 def _record(code: str, message: str, fallback: str) -> None:
247 append_diagnostic(
248 result.diagnostics,
249 ImportDiagnostic(
250 code=code,
251 message=message,
252 fallback=fallback,
253 part_path=part_path,
254 slide_index=slide_index,
255 ),
256 )
257
258 return _record
259
260
261 def _make_palette(
262 master: PartRef | None,
263 theme: PartRef | None,
264 options: ConvertOptions,
265 result: ConvertResult,
266 *,
267 part_path: str,
268 slide_index: int | None = None,
269 ) -> ColorPalette:
270 """Create one strict or tolerant palette with structured diagnostics."""
271 return ColorPalette(
272 master,
273 theme,
274 strict=options.strict,
275 diagnostic_sink=_palette_diagnostic_sink(
276 result,
277 part_path=part_path,
278 slide_index=slide_index,
279 ),
280 )
281
282
283 # ---------------------------------------------------------------------------
284 # Entry
285 # ---------------------------------------------------------------------------
286
287 def convert_pptx_to_svg(
288 pptx_path: Path,
289 output_dir: Path | None = None,
290 options: ConvertOptions | None = None,
291 ) -> ConvertResult:
292 """Convert a .pptx file to one SVG per slide.
293
294 Args:
295 pptx_path: Source .pptx file.
296 output_dir: When given, write svg/<slide_NN>.svg + media files there.
297 When None, files are not written; callers can read SlideArtifact.svg.
298 options: ConvertOptions; defaults to ConvertOptions().
299
300 Returns:
301 ConvertResult with per-slide SVG strings and resolved theme info.
302 """
303 options = options or ConvertOptions()
304 if options.inheritance_mode not in {"flat", "layered", "both"}:
305 raise ValueError(
306 f"inheritance_mode must be 'flat', 'layered', or 'both', "
307 f"got {options.inheritance_mode!r}"
308 )
309 if not options.embed_images:
310 _validate_media_subdir(options.media_subdir)
311 emit_layered = options.inheritance_mode in {"layered", "both"}
312 emit_flat = options.inheritance_mode in {"flat", "both"}
313 result = ConvertResult(
314 source_file=pptx_path.name,
315 strict=options.strict,
316 )
317
318 with OoxmlPackage(pptx_path) as pkg:
319 result.canvas_px = pkg.slide_size_px
320
321 # Default theme summary is kept for compatibility; conversion itself
322 # resolves palette/fonts per slide master.
323 first_slide = pkg.get_slide(1)
324 default_master = first_slide.master if first_slide else None
325 default_theme = pkg.resolve_theme(default_master)
326 palette = _make_palette(
327 default_master,
328 default_theme,
329 options,
330 result,
331 part_path=default_theme.path if default_theme is not None else "",
332 )
333 if default_theme is not None:
334 result.theme_colors, result.theme_fonts = _extract_theme_info(default_theme, palette)
335
336 for master in pkg.iter_all_masters():
337 theme = pkg.resolve_theme(master) or default_theme
338 pal = _make_palette(
339 master,
340 theme,
341 options,
342 result,
343 part_path=master.path,
344 )
345 colors, fonts = _extract_theme_info(theme, pal) if theme is not None else ({}, {})
346 result.master_themes[master.path] = {
347 "themePath": theme.path if theme is not None else None,
348 "colors": colors,
349 "fonts": fonts,
350 }
351
352 # Per-slide conversion. The primary view is layered when emitted
353 # (template designers care most about that one); the flat view is
354 # rendered alongside when needed.
355 primary_mode = "layered" if emit_layered else "flat"
356 for slide in pkg.iter_slides():
357 _read_back_slide_transition(
358 pkg,
359 slide,
360 result,
361 options,
362 )
363 slide_theme = pkg.resolve_theme(slide.master) or default_theme
364 slide_palette = _make_palette(
365 slide.master,
366 slide_theme,
367 options,
368 result,
369 part_path=slide.part.path,
370 slide_index=slide.index,
371 )
372 _colors, slide_fonts = _extract_theme_info(slide_theme, slide_palette) if slide_theme is not None else ({}, result.theme_fonts)
373 artifact = _convert_slide(
374 pkg,
375 slide,
376 slide_palette,
377 options,
378 result.diagnostics,
379 slide_fonts,
380 inheritance_mode=primary_mode,
381 )
382 result.slides.append(artifact)
383 _read_back_slide_animation(
384 pkg,
385 slide,
386 artifact,
387 result,
388 options,
389 )
390 if emit_layered and emit_flat:
391 for slide in pkg.iter_slides():
392 slide_theme = pkg.resolve_theme(slide.master) or default_theme
393 slide_palette = _make_palette(
394 slide.master,
395 slide_theme,
396 options,
397 result,
398 part_path=slide.part.path,
399 slide_index=slide.index,
400 )
401 _colors, slide_fonts = _extract_theme_info(slide_theme, slide_palette) if slide_theme is not None else ({}, result.theme_fonts)
402 artifact = _convert_slide(
403 pkg,
404 slide,
405 slide_palette,
406 options,
407 result.diagnostics,
408 slide_fonts,
409 inheritance_mode="flat",
410 )
411 result.flat_slides.append(artifact)
412
413 # Layered mode: also render each master / layout once.
414 if emit_layered:
415 _convert_inheritance_parts(pkg, default_theme, options, result)
416
417 if output_dir is not None:
418 _write_artifacts(output_dir, result, options)
419
420 return result
421
422
423 def _read_back_slide_transition(
424 pkg: OoxmlPackage,
425 slide: SlideRef,
426 result: ConvertResult,
427 options: ConvertOptions,
428 ) -> None:
429 """Recover one supported slide transition into the sidecar."""
430 try:
431 transition = import_slide_transition(
432 pkg,
433 slide,
434 media_subdir=options.media_subdir,
435 )
436 except TransitionImportError as exc:
437 message = f"Slide transition was not reconstructed: {exc}"
438 if options.strict:
439 raise ValueError(message) from exc
440 append_diagnostic(
441 result.diagnostics,
442 ImportDiagnostic(
443 code="transition-not-reconstructed",
444 message=message,
445 fallback=(
446 "keep this transition in the source PPTX through direct "
447 "native preservation"
448 ),
449 part_path=slide.part.path,
450 slide_index=slide.index,
451 ),
452 )
453 else:
454 if transition is not None:
455 slides = result.animation_config["slides"]
456 if not isinstance(slides, dict):
457 raise RuntimeError("internal animations.json slides must be an object")
458 slides[f"slide_{slide.index:02d}"] = {
459 "transition": transition.config,
460 }
461 for filename, payload in transition.media_files.items():
462 existing = result.animation_media_files.get(filename)
463 if existing is not None and existing != payload:
464 raise RuntimeError(
465 "Transition sound filename collision with different bytes: "
466 f"{filename}"
467 )
468 result.animation_media_files[filename] = payload
469
470
471
472 def _read_back_slide_animation(
473 pkg: OoxmlPackage,
474 slide: SlideRef,
475 artifact: SlideArtifact,
476 result: ConvertResult,
477 options: ConvertOptions,
478 ) -> None:
479 """Recover one finite object-animation sequence into the sidecar."""
480 try:
481 animation = import_slide_animation(
482 pkg,
483 slide,
484 slide_svg=artifact.svg,
485 )
486 except AnimationImportError as exc:
487 message = f"Object animation timing was not reconstructed: {exc}"
488 if options.strict:
489 raise ValueError(message) from exc
490 append_diagnostic(
491 result.diagnostics,
492 ImportDiagnostic(
493 code="animation-not-reconstructed",
494 message=message,
495 fallback=(
496 "keep this timing in the source PPTX through direct "
497 "native preservation"
498 ),
499 part_path=slide.part.path,
500 slide_index=slide.index,
501 ),
502 )
503 return
504 if animation is None:
505 return
506
507 slides = result.animation_config["slides"]
508 if not isinstance(slides, dict):
509 raise RuntimeError("internal animations.json slides must be an object")
510 slide_config = slides.setdefault(f"slide_{slide.index:02d}", {})
511 if not isinstance(slide_config, dict):
512 raise RuntimeError("internal animations.json slide row must be an object")
513 slide_config["groups"] = animation.groups
514
515
516 def _convert_slide(
517 pkg: OoxmlPackage,
518 slide: SlideRef,
519 palette: ColorPalette,
520 options: ConvertOptions,
521 diagnostics: list[ImportDiagnostic],
522 theme_fonts: dict[str, str] | None = None,
523 *,
524 inheritance_mode: str | None = None,
525 ) -> SlideArtifact:
526 """Convert a single slide via the full shape pipeline.
527
528 ``inheritance_mode`` overrides ``options.inheritance_mode`` so the
529 orchestrator can render the same slide twice (once layered, once flat)
530 when the user asked for ``"both"``. Pass ``"flat"`` or ``"layered"``;
531 ``None`` falls back to ``options.inheritance_mode`` (used by direct
532 callers that want a single mode).
533 """
534 mode = inheritance_mode or options.inheritance_mode
535 if mode == "both":
536 mode = "layered" # primary view in both-mode
537 show_inherited_shapes = part_show_master_sp(slide.part)
538 svg, media = assemble_slide(
539 pkg, slide, palette,
540 theme_fonts=theme_fonts,
541 media_subdir=options.media_subdir,
542 embed_images=options.embed_images,
543 keep_hidden=options.keep_hidden,
544 inheritance_mode=mode,
545 asset_name_map=options.asset_name_map,
546 strict=options.strict,
547 diagnostics=diagnostics,
548 )
549 return SlideArtifact(
550 index=slide.index,
551 svg=svg,
552 media_files=media,
553 layout_part_path=slide.layout.path if slide.layout else None,
554 master_part_path=slide.master.path if slide.master else None,
555 show_inherited_shapes=show_inherited_shapes,
556 )
557
558
559 def _convert_inheritance_parts(
560 pkg: OoxmlPackage,
561 default_theme: PartRef | None,
562 options: ConvertOptions,
563 result: ConvertResult,
564 ) -> None:
565 """Render every master and layout in the deck to its own SVG (layered mode).
566
567 We deliberately render *all* masters / layouts, not only the ones a slide
568 references. Multi-style template packages routinely ship more design
569 surfaces than the embedded sample slides exercise, and dropping unused
570 ones discards the bulk of the template's design intent.
571 """
572 # Collect unique parts in document order so output filenames are
573 # deterministic for a given .pptx.
574 seen_masters: dict[str, PartRef] = {}
575 for master in pkg.iter_all_masters():
576 if master.path not in seen_masters:
577 seen_masters[master.path] = master
578
579 layouts_with_parent: list[tuple[PartRef, PartRef]] = []
580 seen_layout_paths: set[str] = set()
581 for layout, parent_master in pkg.iter_all_layouts_with_parent():
582 if layout.path in seen_layout_paths:
583 continue
584 seen_layout_paths.add(layout.path)
585 layouts_with_parent.append((layout, parent_master))
586
587 for seq, part in enumerate(seen_masters.values(), start=1):
588 theme = pkg.resolve_theme(part) or default_theme
589 palette = _make_palette(
590 part,
591 theme,
592 options,
593 result,
594 part_path=part.path,
595 )
596 _colors, fonts = _extract_theme_info(theme, palette) if theme is not None else ({}, result.theme_fonts)
597 result.masters.append(_render_part(
598 pkg, part, palette, options, result.diagnostics, fonts,
599 role="master", seq=seq, theme_part=theme,
600 ))
601 for seq, (layout, parent_master) in enumerate(layouts_with_parent, start=1):
602 theme = pkg.resolve_theme(parent_master) or default_theme
603 palette = _make_palette(
604 parent_master,
605 theme,
606 options,
607 result,
608 part_path=layout.path,
609 )
610 _colors, fonts = _extract_theme_info(theme, palette) if theme is not None else ({}, result.theme_fonts)
611 result.layouts.append(_render_part(
612 pkg, layout, palette, options, result.diagnostics, fonts,
613 role="layout", seq=seq, parent_master=parent_master,
614 theme_part=theme,
615 ))
616
617
618 def _render_part(
619 pkg: OoxmlPackage,
620 part: PartRef,
621 palette: ColorPalette,
622 options: ConvertOptions,
623 diagnostics: list[ImportDiagnostic],
624 theme_fonts: dict[str, str],
625 *,
626 role: str,
627 seq: int,
628 parent_master: PartRef | None = None,
629 theme_part: PartRef | None = None,
630 ) -> PartArtifact:
631 """Render a master/layout part, returning a PartArtifact with output filename."""
632 svg, media = assemble_part_solo(
633 pkg, part, palette,
634 role=role,
635 parent_master=parent_master,
636 theme_fonts=theme_fonts,
637 media_subdir=options.media_subdir,
638 embed_images=options.embed_images,
639 keep_hidden=options.keep_hidden,
640 asset_name_map=options.asset_name_map,
641 strict=options.strict,
642 diagnostics=diagnostics,
643 )
644 stem = PurePosixPath(part.path).stem # e.g. "slideLayout3"
645 safe_stem = re.sub(r"[^A-Za-z0-9_-]+", "_", stem).strip("_") or role
646 filename = f"{role}_{seq:02d}_{safe_stem}.svg"
647 return PartArtifact(
648 role=role,
649 part_path=part.path,
650 filename=filename,
651 svg=svg,
652 media_files=media,
653 parent_master_part_path=parent_master.path if parent_master is not None else None,
654 theme_part_path=theme_part.path if theme_part is not None else None,
655 show_master_shapes=(
656 part_show_master_sp(part) if role == "layout" else True
657 ),
658 )
659
660
661 def _path_lexists(path: Path) -> bool:
662 """Return whether a path or symlink exists without following the symlink."""
663 return path.exists() or path.is_symlink()
664
665
666 def _managed_svg_paths(output_dir: Path) -> list[Path]:
667 """Return converter-owned SVG files without traversing user directories."""
668 managed: list[Path] = []
669 for dirname, filename_re in (
670 ("svg", _MANAGED_PRIMARY_SVG_RE),
671 ("svg-flat", _MANAGED_FLAT_SVG_RE),
672 ):
673 svg_dir = output_dir / dirname
674 if svg_dir.is_symlink():
675 managed.append(svg_dir)
676 continue
677 if not svg_dir.is_dir():
678 continue
679 managed.extend(
680 path
681 for path in svg_dir.iterdir()
682 if filename_re.fullmatch(path.name)
683 and (path.is_file() or path.is_symlink())
684 )
685 inheritance = svg_dir / "inheritance.json"
686 if dirname == "svg" and _path_lexists(inheritance):
687 managed.append(inheritance)
688 return managed
689
690
691 def _managed_report_artifact_paths(output_dir: Path) -> set[Path]:
692 """Return optional artifacts owned by the previous conversion report."""
693 report_path = output_dir / "conversion-report.json"
694 if report_path.is_symlink() or not report_path.is_file():
695 return set()
696 try:
697 report = json.loads(report_path.read_text(encoding="utf-8"))
698 except (OSError, UnicodeError, json.JSONDecodeError):
699 return set()
700 artifacts = report.get("artifacts")
701 if not isinstance(artifacts, dict):
702 return set()
703 if artifacts.get("animationConfig") != "animations.json":
704 return set()
705
706 managed = {Path("animations.json")}
707 animation_media = artifacts.get("animationMedia")
708 if not isinstance(animation_media, list):
709 return managed
710 for value in animation_media:
711 if not isinstance(value, str):
712 continue
713 path = Path(value)
714 if (
715 path.drive
716 or path.anchor
717 or path.is_absolute()
718 or not path.parts
719 or ".." in path.parts
720 or not _MANAGED_TRANSITION_SOUND_RE.fullmatch(path.name)
721 ):
722 continue
723 managed.add(path)
724 return managed
725
726
727 def _referenced_local_paths(
728 output_dir: Path,
729 svg_paths: list[Path],
730 ) -> set[Path]:
731 """Resolve local media referenced by converter-owned SVGs."""
732 referenced: set[Path] = set()
733 output_abs = output_dir.absolute()
734 for svg_path in svg_paths:
735 if svg_path.is_symlink() or not svg_path.is_file():
736 continue
737 try:
738 svg_text = svg_path.read_text(encoding="utf-8")
739 except (OSError, UnicodeError):
740 continue
741 for raw_href in _SVG_HREF_RE.findall(svg_text):
742 href = unescape(raw_href)
743 parsed = urlsplit(href)
744 if parsed.scheme or parsed.netloc or not parsed.path:
745 continue
746 href_path = unquote(parsed.path)
747 if Path(href_path).is_absolute():
748 continue
749 target = Path(os.path.normpath(str(svg_path.parent / href_path)))
750 try:
751 relative = target.absolute().relative_to(output_abs)
752 except ValueError:
753 continue
754 if relative.parts:
755 referenced.add(relative)
756 return referenced
757
758
759 def _validated_relative_paths(paths: set[str | Path]) -> set[Path]:
760 """Normalize caller-supplied managed paths and reject output escapes."""
761 normalized: set[Path] = set()
762 for value in paths:
763 path = Path(value)
764 if (
765 path.drive
766 or path.anchor
767 or path.is_absolute()
768 or not path.parts
769 or ".." in path.parts
770 ):
771 raise ValueError(f"Managed artifact path must stay relative: {value}")
772 normalized.add(path)
773 return normalized
774
775
776 def _reject_symlink_ancestors(
777 root: Path,
778 relative_paths: set[Path],
779 ) -> None:
780 """Reject managed paths that would traverse a preserved user symlink."""
781 for relative in relative_paths:
782 current = root
783 for component in relative.parts[:-1]:
784 current /= component
785 if current.is_symlink():
786 raise RuntimeError(
787 "Managed artifact path crosses an unmanaged symlink: "
788 f"{relative}"
789 )
790
791
792 def _remove_managed_paths(candidate_dir: Path, relative_paths: set[Path]) -> None:
793 """Remove only the previous converter roster from a candidate workspace."""
794 _reject_symlink_ancestors(candidate_dir, relative_paths)
795 parents: set[Path] = set()
796 for relative in sorted(
797 relative_paths,
798 key=lambda item: len(item.parts),
799 reverse=True,
800 ):
801 target = candidate_dir / relative
802 if target.is_symlink() or target.is_file():
803 target.unlink()
804 elif target.is_dir():
805 raise RuntimeError(
806 "Managed artifact path collides with a preserved directory: "
807 f"{relative}"
808 )
809 parent = target.parent
810 while parent != candidate_dir:
811 parents.add(parent)
812 parent = parent.parent
813
814 for parent in sorted(parents, key=lambda item: len(item.parts), reverse=True):
815 if parent.is_symlink() or not parent.is_dir():
816 continue
817 try:
818 parent.rmdir()
819 except OSError:
820 pass
821
822
823 def _overlay_staged_tree(staged_dir: Path, candidate_dir: Path) -> None:
824 """Overlay generated artifacts without overwriting unmanaged user files."""
825 for source in sorted(staged_dir.rglob("*")):
826 relative = source.relative_to(staged_dir)
827 target = candidate_dir / relative
828 _reject_symlink_ancestors(candidate_dir, {relative})
829 if source.is_symlink():
830 raise RuntimeError(
831 f"Generated artifact must not be a symlink: {relative}"
832 )
833 if source.is_dir():
834 if (
835 target.is_symlink()
836 or (_path_lexists(target) and not target.is_dir())
837 ):
838 raise RuntimeError(
839 f"Generated artifact collides with unmanaged path: {relative}"
840 )
841 target.mkdir(parents=True, exist_ok=True)
842 continue
843 target.parent.mkdir(parents=True, exist_ok=True)
844 if _path_lexists(target):
845 if target.is_dir() or target.is_symlink():
846 raise RuntimeError(
847 f"Generated artifact collides with unmanaged path: {relative}"
848 )
849 if target.read_bytes() != source.read_bytes():
850 raise RuntimeError(
851 f"Generated artifact collides with unmanaged file: {relative}"
852 )
853 shutil.copy2(source, target)
854
855
856 def publish_staged_workspace(
857 output_dir: Path,
858 staged_dir: Path,
859 *,
860 managed_root_files: set[str | Path] | None = None,
861 managed_relative_paths: set[str | Path] | None = None,
862 ) -> None:
863 """Atomically publish generated artifacts while preserving user files.
864
865 Converter-owned SVGs, their local media references, and the named managed
866 artifacts are replaced as one roster. Everything else already present in
867 the output directory is copied into the candidate unchanged.
868 """
869 output_dir = output_dir.absolute()
870 staged_dir = staged_dir.absolute()
871 if (
872 output_dir == staged_dir
873 or output_dir in staged_dir.parents
874 or staged_dir in output_dir.parents
875 ):
876 raise ValueError(
877 "Staged and output workspaces must not contain one another"
878 )
879 output_resolved = output_dir.resolve(strict=False)
880 try:
881 Path.cwd().resolve().relative_to(output_resolved)
882 except ValueError:
883 pass
884 else:
885 raise RuntimeError(
886 "Output workspace must not contain the current working directory"
887 )
888 if not staged_dir.is_dir():
889 raise ValueError(f"Staged workspace does not exist: {staged_dir}")
890 if (
891 output_dir.is_symlink()
892 or (_path_lexists(output_dir) and not output_dir.is_dir())
893 ):
894 raise RuntimeError(f"Output path must be a real directory: {output_dir}")
895
896 output_dir.parent.mkdir(parents=True, exist_ok=True)
897 transaction_dir = Path(tempfile.mkdtemp(
898 prefix=f".{output_dir.name}.publish-",
899 dir=output_dir.parent,
900 ))
901 candidate_dir = transaction_dir / "candidate"
902 backup_dir = transaction_dir / "previous"
903 preserve_backup = False
904
905 try:
906 if output_dir.is_dir():
907 shutil.copytree(output_dir, candidate_dir, symlinks=True)
908 else:
909 candidate_dir.mkdir()
910
911 managed_svg = _managed_svg_paths(output_dir)
912 relative_paths = {
913 path.relative_to(output_dir)
914 for path in managed_svg
915 }
916 relative_paths.update(_referenced_local_paths(output_dir, managed_svg))
917 relative_paths.add(Path("conversion-report.json"))
918 relative_paths.update(_managed_report_artifact_paths(output_dir))
919 relative_paths.update(_validated_relative_paths(managed_root_files or set()))
920 relative_paths.update(_validated_relative_paths(managed_relative_paths or set()))
921 _remove_managed_paths(candidate_dir, relative_paths)
922 _overlay_staged_tree(staged_dir, candidate_dir)
923
924 if output_dir.is_dir():
925 try:
926 os.replace(output_dir, backup_dir)
927 os.replace(candidate_dir, output_dir)
928 except BaseException as publish_error:
929 try:
930 if _path_lexists(backup_dir):
931 if _path_lexists(output_dir):
932 failed_output = transaction_dir / "failed-publish"
933 os.replace(output_dir, failed_output)
934 os.replace(backup_dir, output_dir)
935 except BaseException as restore_error:
936 if (
937 not _path_lexists(backup_dir)
938 and _path_lexists(output_dir)
939 ):
940 raise publish_error
941 preserve_backup = _path_lexists(backup_dir)
942 raise RuntimeError(
943 "Failed to publish the new workspace and restore the "
944 "previous workspace; recovery directory: "
945 f"{transaction_dir}"
946 ) from restore_error
947 raise
948 else:
949 os.replace(candidate_dir, output_dir)
950 finally:
951 if not preserve_backup:
952 shutil.rmtree(transaction_dir, ignore_errors=True)
953
954
955 def _write_artifact_tree(
956 output_dir: Path,
957 result: ConvertResult,
958 options: ConvertOptions,
959 ) -> None:
960 """Write a complete converter roster into an empty staging directory.
961
962 Layout:
963 - ``svg/`` primary view (layered when emitted, otherwise flat)
964 - ``svg-flat/`` self-contained per-slide renders (only in "both" mode)
965 - ``<media_subdir>/`` shared image assets, referenced by both views
966 """
967 output_dir.mkdir(parents=True, exist_ok=True)
968 svg_dir = output_dir / "svg"
969 svg_dir.mkdir(exist_ok=True)
970 media_dir = output_dir / options.media_subdir
971 media_written: dict[str, bytes] = {}
972
973 def _collect_media(media: dict[str, bytes]) -> None:
974 for filename, blob in media.items():
975 _validate_media_filename(filename)
976 if filename in media_written:
977 if media_written[filename] != blob:
978 raise RuntimeError(
979 f"Asset filename collision with different bytes: {filename}"
980 )
981 continue
982 media_written[filename] = blob
983
984 # Layered mode: write masters and layouts first so they sort ahead of slides.
985 for art in result.masters:
986 (svg_dir / art.filename).write_text(art.svg, encoding="utf-8")
987 _collect_media(art.media_files)
988 for art in result.layouts:
989 (svg_dir / art.filename).write_text(art.svg, encoding="utf-8")
990 _collect_media(art.media_files)
991
992 # Slides (primary view).
993 for art in result.slides:
994 target = svg_dir / f"slide_{art.index:02d}.svg"
995 target.write_text(art.svg, encoding="utf-8")
996 _collect_media(art.media_files)
997 _collect_media(result.animation_media_files)
998
999 # Inheritance graph alongside the layered SVGs (only meaningful when we
1000 # actually emitted a layered view).
1001 if options.inheritance_mode in {"layered", "both"}:
1002 _write_inheritance_json(svg_dir, result)
1003
1004 # Flat companion view (only when result.flat_slides is populated).
1005 if result.flat_slides:
1006 flat_dir = output_dir / "svg-flat"
1007 flat_dir.mkdir(exist_ok=True)
1008 for art in result.flat_slides:
1009 target = flat_dir / f"slide_{art.index:02d}.svg"
1010 target.write_text(art.svg, encoding="utf-8")
1011 _collect_media(art.media_files)
1012
1013 _write_animation_config(output_dir, result)
1014 _write_conversion_report(output_dir, result, options)
1015 if media_written:
1016 media_dir.mkdir(parents=True, exist_ok=True)
1017 for filename, blob in media_written.items():
1018 target = media_dir / filename
1019 if _path_lexists(target):
1020 if (
1021 target.is_symlink()
1022 or not target.is_file()
1023 or target.read_bytes() != blob
1024 ):
1025 raise RuntimeError(
1026 f"Asset filename collision with different bytes: {filename}"
1027 )
1028 continue
1029 target.write_bytes(blob)
1030
1031
1032 def _write_artifacts(
1033 output_dir: Path,
1034 result: ConvertResult,
1035 options: ConvertOptions,
1036 ) -> None:
1037 """Stage a complete conversion, then atomically publish its exact roster."""
1038 output_dir = output_dir.absolute()
1039 output_dir.parent.mkdir(parents=True, exist_ok=True)
1040 staging_root = Path(tempfile.mkdtemp(
1041 prefix=f".{output_dir.name}.convert-",
1042 dir=output_dir.parent,
1043 ))
1044 staged_dir = staging_root / "generated"
1045 try:
1046 _write_artifact_tree(staged_dir, result, options)
1047 publish_staged_workspace(output_dir, staged_dir)
1048 finally:
1049 shutil.rmtree(staging_root, ignore_errors=True)
1050
1051
1052 def _write_conversion_report(
1053 output_dir: Path,
1054 result: ConvertResult,
1055 options: ConvertOptions,
1056 ) -> None:
1057 """Write the user-visible tolerant-import report."""
1058 animation_media = [
1059 (PurePosixPath(options.media_subdir) / filename).as_posix()
1060 for filename in sorted(result.animation_media_files)
1061 ]
1062 report = {
1063 "schemaVersion": 1,
1064 "source": result.source_file,
1065 "mode": "strict" if result.strict else "tolerant",
1066 "summary": {
1067 "slides": len(result.slides),
1068 "warnings": len(result.diagnostics),
1069 },
1070 "artifacts": {
1071 "animationConfig": "animations.json",
1072 "animationMedia": animation_media,
1073 },
1074 "diagnostics": [item.to_dict() for item in result.diagnostics],
1075 }
1076 (output_dir / "conversion-report.json").write_text(
1077 json.dumps(report, ensure_ascii=False, indent=2) + "\n",
1078 encoding="utf-8",
1079 )
1080
1081
1082 def _write_animation_config(output_dir: Path, result: ConvertResult) -> None:
1083 """Write the canonical transition/object-motion sidecar."""
1084 errors = list(
1085 dict.fromkeys(
1086 validate_transition_config(result.animation_config)
1087 + validate_animation_config_errors(result.animation_config)
1088 )
1089 )
1090 if errors:
1091 raise RuntimeError(
1092 "Generated animations.json is invalid: " + "; ".join(errors)
1093 )
1094 (output_dir / "animations.json").write_text(
1095 json.dumps(
1096 result.animation_config,
1097 ensure_ascii=False,
1098 indent=2,
1099 ) + "\n",
1100 encoding="utf-8",
1101 )
1102
1103
1104 def _write_inheritance_json(svg_dir: Path, result: ConvertResult) -> None:
1105 """Record layered parentage plus source-owned shape-visibility booleans."""
1106 layout_by_path = {art.part_path: art.filename for art in result.layouts}
1107 master_by_path = {art.part_path: art.filename for art in result.masters}
1108
1109 inheritance = {
1110 "masters": [
1111 {
1112 "file": art.filename,
1113 "partPath": art.part_path,
1114 "themePath": art.theme_part_path,
1115 }
1116 for art in result.masters
1117 ],
1118 "layouts": [
1119 {
1120 "file": art.filename,
1121 "partPath": art.part_path,
1122 "master": master_by_path.get(art.parent_master_part_path or ""),
1123 "parentPartPath": art.parent_master_part_path,
1124 "themePath": art.theme_part_path,
1125 "showMasterShapes": art.show_master_shapes,
1126 }
1127 for art in result.layouts
1128 ],
1129 "slides": [
1130 {
1131 "file": f"slide_{slide.index:02d}.svg",
1132 "index": slide.index,
1133 "layout": layout_by_path.get(slide.layout_part_path or ""),
1134 "master": master_by_path.get(slide.master_part_path or ""),
1135 "showInheritedShapes": slide.show_inherited_shapes,
1136 }
1137 for slide in result.slides
1138 ],
1139 }
1140 (svg_dir / "inheritance.json").write_text(
1141 json.dumps(inheritance, ensure_ascii=False, indent=2) + "\n",
1142 encoding="utf-8",
1143 )
1144
1144 lines PYTHON