936 lines
34 KiB
Python
936 lines
34 KiB
Python
"""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 './<media_subdir>/<filename>'.
|
||
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/<slide_NN>.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)
|
||
- ``<media_subdir>/`` 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",
|
||
)
|