Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compile current-page SVG sources with a separate released template, locally."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
from urllib.parse import unquote, urlsplit
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
import template_ref as ref
|
||||
|
||||
SVG = "http://www.w3.org/2000/svg"
|
||||
ET.register_namespace("", SVG)
|
||||
DEPENDENCIES = {"python-pptx": "pptx", "lxml": "lxml", "Pillow": "PIL",
|
||||
"XlsxWriter": "xlsxwriter", "typing_extensions": "typing_extensions",
|
||||
"defusedxml": "defusedxml", "numpy": "numpy", "openpyxl": "openpyxl",
|
||||
"et_xmlfile": "et_xmlfile"}
|
||||
|
||||
|
||||
def regular(path: Path, label: str, *, directory=False) -> Path:
|
||||
path = Path(os.path.abspath(path))
|
||||
ref.require(not any(item.is_symlink() for item in (path, *path.parents)), f"{label}: symlinks forbidden")
|
||||
ref.require(path.is_dir() if directory else path.is_file(), f"{label}: missing {'directory' if directory else 'file'}: {path}")
|
||||
return path
|
||||
|
||||
|
||||
def boundary_file(root: Path, value: str, label: str) -> Path:
|
||||
ref.local_path(root, value, label)
|
||||
return regular(root / value, label)
|
||||
|
||||
|
||||
def runtime_root() -> Path:
|
||||
# The installed Skill and a prepared portable project both keep scripts/
|
||||
# adjacent to runtime/. No machine path or other installed Skill is read.
|
||||
root = Path(__file__).resolve().parent.parent / "runtime/ppt-master"
|
||||
regular(root / "scripts/attribution_guard.py", "bundled attribution guard")
|
||||
return root
|
||||
|
||||
|
||||
def load_compiler():
|
||||
if sys.version_info < (3, 12):
|
||||
raise ValueError("Native compiler requires Python 3.12+; metadata-only project commands require Python 3.10+")
|
||||
missing = [name for name, module in DEPENDENCIES.items() if importlib.util.find_spec(module) is None]
|
||||
if missing:
|
||||
raise ValueError("Native compiler dependencies missing: " + ", ".join(missing)
|
||||
+ "; provision runtime/requirements.txt using the installation environment")
|
||||
sys.path.insert(0, str(runtime_root() / "scripts"))
|
||||
from attribution_guard import require_skill_integrity
|
||||
require_skill_integrity()
|
||||
from svg_to_pptx.pptx_package.builder import create_pptx_with_native_svg
|
||||
from svg_to_pptx.drawingml.theme_fonts import MasterTextStyleSpec
|
||||
from finalize_theme import finalize, typography, verify, preserve_declared_font_families
|
||||
return create_pptx_with_native_svg, MasterTextStyleSpec, finalize, typography, verify, preserve_declared_font_families
|
||||
|
||||
|
||||
def prepare_svg(source: Path, target: Path, allowed: Path):
|
||||
regular(source, "SVG source")
|
||||
ref.require(source.is_relative_to(allowed), "SVG source is outside its declared resource boundary")
|
||||
text = source.read_text(encoding="utf-8")
|
||||
ref.require(not re.search(r"<!DOCTYPE|<!ENTITY", text, re.I), "SVG declarations/entities are forbidden")
|
||||
root = ET.fromstring(text)
|
||||
ref.require(root.tag == "{" + SVG + "}svg", "Source must be an SVG document")
|
||||
for element in root.iter():
|
||||
kind = element.tag.rsplit("}", 1)[-1]
|
||||
ref.require(kind not in ("script", "foreignObject"), f"Unsupported active SVG element: {kind}")
|
||||
for key, value in list(element.attrib.items()):
|
||||
ref.require(not key.lower().startswith("on"), "SVG event handlers are forbidden")
|
||||
if key.rsplit("}", 1)[-1] != "href":
|
||||
continue
|
||||
if value.startswith("#"):
|
||||
continue
|
||||
ref.require(kind == "image", "Only fragment references and local bitmap image href are supported")
|
||||
if value.startswith("data:"):
|
||||
ref.require(bool(re.fullmatch(r"data:image/(?:png|jpeg);base64,[A-Za-z0-9+/=\s]+", value)), "Only PNG/JPEG data images are supported")
|
||||
base64.b64decode(value.split(",", 1)[1], validate=True)
|
||||
continue
|
||||
parsed = urlsplit(value)
|
||||
decoded = unquote(parsed.path)
|
||||
ref.require(not parsed.scheme and not parsed.netloc and not parsed.query and not parsed.fragment
|
||||
and not decoded.startswith(("/", "\\")) and "\\" not in decoded and ":" not in decoded,
|
||||
"Image href must be a relative local bitmap path")
|
||||
path = Path(os.path.abspath(source.parent / decoded))
|
||||
ref.require(path.is_relative_to(allowed), f"Image escapes declared resource boundary: {value}")
|
||||
regular(path, "SVG bitmap")
|
||||
ref.require(path.suffix.lower() in (".png", ".jpg", ".jpeg"), "Only PNG/JPEG bitmap references are supported")
|
||||
mime = "image/png" if path.suffix.lower() == ".png" else "image/jpeg"
|
||||
element.set(key, f"data:{mime};base64," + base64.b64encode(path.read_bytes()).decode("ascii"))
|
||||
# Local paint/filter references are fragments; other CSS url() forms would
|
||||
# bypass image boundary checks and are intentionally refused.
|
||||
for value in re.findall(r"url\(\s*['\"]?([^) '\"]+)['\"]?\s*\)", text):
|
||||
ref.require(value.startswith("#"), "SVG CSS url() supports local fragments only")
|
||||
ET.ElementTree(root).write(target, encoding="utf-8", xml_declaration=True)
|
||||
return root
|
||||
|
||||
|
||||
def export(manifest_path: Path, theme_id: str, sources: list[Path], output: Path,
|
||||
notes: dict | None = None, source_root: Path | None = None):
|
||||
manifest_path = regular(manifest_path, "template manifest")
|
||||
manifest = ref.load_manifest(manifest_path)
|
||||
ref.require(manifest["schema_version"] == 2, "Native SVG export requires manifest schema 2; legacy native PPTX filling uses the existing application route")
|
||||
selection = argparse.Namespace(theme=theme_id, mode=None, layout=None, route="native", workflow="interactive-page")
|
||||
receipt = ref.resolve(manifest_path, manifest, selection)
|
||||
template_root = manifest_path.parent
|
||||
variant = manifest["themes"][theme_id]
|
||||
workspace = regular(template_root / variant["workspace"], "template workspace", directory=True)
|
||||
style = ref.read_json(boundary_file(template_root, manifest["style"], "style"), None)
|
||||
theme = ref.read_json(boundary_file(template_root, variant["theme"], "theme"), None)
|
||||
roster = ref.read_json(regular(workspace / "templates/roster.json", "template roster"), None)
|
||||
layouts = {item["id"]: item["name"] for item in manifest["layouts"]}
|
||||
ref.require(set(roster) == set(layouts), "Template roster IDs must match all manifest layouts")
|
||||
ref.require(len(set(layouts.values())) == len(layouts), "Native layout friendly names must be unique")
|
||||
ref.require(sources, "At least one current-page SVG source is required")
|
||||
sources = [regular(path, "page source") for path in sources]
|
||||
stems = [path.stem for path in sources]
|
||||
ref.require(len(set(stems)) == len(stems), "SVG source stems must be unique for notes binding")
|
||||
notes = notes or {}
|
||||
ref.require(isinstance(notes, dict) and set(notes) <= set(stems)
|
||||
and all(isinstance(value, str) for value in notes.values()), "Notes must map source stems to strings, without unknown pages")
|
||||
source_root = regular(source_root, "source resource root", directory=True) if source_root else None
|
||||
output = Path(os.path.abspath(output))
|
||||
ref.require(output.suffix.lower() == ".pptx", "Output must end in .pptx")
|
||||
ref.require(not output.exists() and not output.is_symlink(), "Export refuses to overwrite an existing output")
|
||||
ref.require(not any(parent.is_symlink() for parent in output.parents), "Output ancestors cannot be symlinks")
|
||||
compiler, master_style, finalize, typography, verify, preserve_fonts = load_compiler()
|
||||
_, _, _, title_hpt, body_hpt = typography(style)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix="native-export-", dir=output.parent) as temporary:
|
||||
temporary = Path(temporary)
|
||||
definitions = []
|
||||
for layout_id, filename in roster.items():
|
||||
source = boundary_file(workspace / "templates", filename, f"layout {layout_id}")
|
||||
target = temporary / f"layout_{layout_id}.svg"
|
||||
root = prepare_svg(source, target, template_root)
|
||||
ref.require(root.get("data-pptx-layout") == layout_id, f"Roster/source layout identity mismatch: {layout_id}")
|
||||
definitions.append(target)
|
||||
public, expected_names, compiled_notes, indexed_notes = [], [], {}, {}
|
||||
for index, source in enumerate(sources):
|
||||
target = temporary / f"page_{index:04d}.svg"
|
||||
allowed = source_root or (template_root if source.is_relative_to(template_root) else source.parent)
|
||||
root = prepare_svg(source, target, allowed)
|
||||
layout_id = root.get("data-pptx-layout")
|
||||
ref.require(layout_id in layouts, f"Source references an undeclared layout: {layout_id}")
|
||||
ref.require(root.get("viewBox") == style["canvas"]["viewbox"], "Source viewBox differs from declared template canvas")
|
||||
public.append(target)
|
||||
expected_names.append(layouts[layout_id])
|
||||
if source.stem in notes:
|
||||
compiled_notes[target.stem] = notes[source.stem]
|
||||
indexed_notes[index] = notes[source.stem]
|
||||
generated = temporary / "generated.pptx"
|
||||
with preserve_fonts(style):
|
||||
success = compiler(svg_files=public, output_path=generated,
|
||||
expected_viewbox=style["canvas"]["viewbox"], verbose=False,
|
||||
transition=None, notes=compiled_notes, enable_notes=bool(notes), animation=None,
|
||||
image_optimize=False, native_objects=True, pptx_structure="structured",
|
||||
use_layout_placeholder_frames=True,
|
||||
master_text_style_spec=master_style(title_hpt=title_hpt, body_hpt=body_hpt),
|
||||
structure_name=manifest["template_id"], layout_definition_files=definitions,
|
||||
workers=1, primary_language=style.get("language"))
|
||||
ref.require(success, "Bundled structured compiler failed")
|
||||
finalize(generated, theme, style)
|
||||
facts = verify(generated, layouts, expected_names, indexed_notes)
|
||||
# Exclusive publication also protects against a writer racing the initial
|
||||
# existence check. A failed compile never disturbs the previous output.
|
||||
created = False
|
||||
try:
|
||||
with output.open("xb") as target, generated.open("rb") as source:
|
||||
created = True
|
||||
while block := source.read(1024 * 1024):
|
||||
target.write(block)
|
||||
except Exception:
|
||||
if created:
|
||||
output.unlink(missing_ok=True)
|
||||
raise
|
||||
return {**facts, "template_id": manifest["template_id"], "template_version": manifest["version"],
|
||||
"theme": theme_id, "output": str(output)}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--manifest", type=Path, required=True)
|
||||
parser.add_argument("--theme", required=True)
|
||||
parser.add_argument("--source", type=Path, action="append", required=True)
|
||||
parser.add_argument("--source-root", type=Path, help="Explicit local boundary for author SVG resources; default is each source directory")
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--notes", type=Path, help="JSON object: source filename stem to speaker-notes string")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
notes = ref.read_json(regular(args.notes, "notes JSON"), None) if args.notes else None
|
||||
print(json.dumps(export(args.manifest, args.theme, args.source, args.output, notes, args.source_root), ensure_ascii=False))
|
||||
return 0
|
||||
except (ref.InputError, OSError, ValueError, KeyError, TypeError, ET.ParseError, ImportError, RuntimeError) as error:
|
||||
print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user