Release standalone PPT template workflow 0.5.0

This commit is contained in:
2026-10-10 21:02:47 +08:00
commit 4e3271ff62
303 changed files with 190946 additions and 0 deletions
+205
View File
@@ -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())