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