Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1,935 @@
|
||||
"""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",
|
||||
)
|
||||
Reference in New Issue
Block a user