#!/usr/bin/env python3 """Portable presentation source projects. Python 3.10+, standard library only.""" from __future__ import annotations import argparse import json import os from pathlib import Path, PurePosixPath import re import shutil import subprocess import sys import tempfile from urllib.parse import unquote, urlsplit import uuid import xml.etree.ElementTree as ET import zipfile import template_ref as ref EXCLUDED = {"exports", "build", ".git", ".venv", "venv", "node_modules", "__pycache__", ".cache", "cache", ".pytest_cache", ".mypy_cache", ".DS_Store"} TOOLS = ("tools/template_ref.py", "tools/ppt_project.py") METADATA = ("README.md", ".gitignore", ".gitattributes") SCOPE = "source paths, hashes and basic PPTX ZIP/XML structure only; rendering, PowerPoint editing and projection unverified" def require(condition: bool, message: str) -> None: ref.require(condition, message) def safe_path(root: Path, raw: object, label: str, *, exists: bool = True) -> Path: text = ref.string(raw, label) relative = PurePosixPath(text) require(text == relative.as_posix() and not relative.is_absolute() and text != "." and ".." not in relative.parts and not any(c in text for c in ("\\", ":", "\x00", "?", "#")) and not any(ord(c) < 32 for c in text), f"{label}: expected project-relative POSIX path") path = root / text for parent in (path, *path.parents): require(not parent.is_symlink(), f"{label}: symlink forbidden: {text}") if parent == root: break require(path.resolve().is_relative_to(root), f"{label}: path escapes project") if exists: require(path.is_file(), f"{label}: missing file {text}") return path def root_path(value: Path) -> Path: root = Path(os.path.abspath(value)) require(not any(p.is_symlink() for p in (root, *root.parents)), "Project root: symlink forbidden") return root def relative(root: Path, path: Path) -> str: return path.relative_to(root).as_posix() def write_json(path: Path, value: dict) -> None: path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n") def atomic_json(path: Path, value: dict) -> None: with tempfile.NamedTemporaryFile(prefix=".lock-", dir=path.parent, delete=False) as stream: temporary = Path(stream.name) try: write_json(temporary, value) os.replace(temporary, path) finally: temporary.unlink(missing_ok=True) def tree_files(root: Path) -> set[Path]: """Prune derived directories and never dereference a package link.""" result = set() for folder, dirs, files in os.walk(root, followlinks=False): base = Path(folder) for name in dirs + files: require(not (base / name).is_symlink(), f"symlink forbidden: {base / name}") dirs[:] = sorted(name for name in dirs if name not in EXCLUDED) for name in sorted(files): if name in EXCLUDED or name.endswith((".pyc", ".pyo")) or name == "environment.local.json": continue path = base / name require(path.is_file(), f"Not a regular file: {path}") result.add(path) return result def selection(root: Path, project: dict) -> tuple[dict, Path, dict, argparse.Namespace]: template = project.get("template") require(isinstance(template, dict), "template: expected object") require(template.get("manifest") == "vendor/template/manifest.json", "template.manifest: expected vendor/template/manifest.json") require(template.get("lock") == "template.lock.json", "template.lock: expected template.lock.json") manifest = safe_path(root, template["manifest"], "template.manifest") value = ref.load_manifest(manifest) theme = ref.identifier(template.get("theme"), "template.theme") authoring = project.get("authoring") require(isinstance(authoring, dict), "authoring: expected object") require(authoring.get("route") in ref.ROUTES, "authoring.route: unsupported route") require(authoring.get("workflow") in ("interactive-page", "batch"), "authoring.workflow: unsupported workflow") args = argparse.Namespace(theme=theme if value["schema_version"] == 2 else None, mode=theme if value["schema_version"] == 1 else None, layout=None, route=authoring["route"], workflow=authoring["workflow"]) return ref.resolve(manifest, value, args), manifest, value, args def hashes(root: Path, paths: set[Path]) -> list[dict]: return [{"path": relative(root, path), "sha256": ref.digest(path)} for path in sorted(paths, key=lambda p: relative(root, p))] def template_snapshot(root: Path, project: dict) -> dict: receipt, manifest, value, _ = selection(root, project) paths = tree_files(root / "vendor/template") for source in paths: if source.suffix.lower() == ".json": validate_json_data(source) if source.suffix.lower() == ".svg": for dependency in references(root, source): require(dependency in paths, f"Template SVG resource is outside packaged source: {relative(root, dependency)}") # Lock every shipped source, including unselected themes, rather than silently # accepting drift outside the resolver's currently selected input closure. return {"schema_version": 1, "template_id": value["template_id"], "version": value["version"], "theme": project["template"]["theme"], "manifest": relative(root, manifest), "inputs": hashes(root, paths)} def verify_template(root: Path, project: dict) -> None: locked = ref.read_json(safe_path(root, "template.lock.json", "template lock"), (1,)) require(locked == template_snapshot(root, project), "Template drift: template.lock.json differs; restore the pinned template or initialize a separate project") def resource_reference(root: Path, source: Path, target: str, *, markdown: bool) -> Path | None: target = target.strip() if target.startswith("#") or (not markdown and target.startswith("data:")): return None parsed = urlsplit(target) if markdown and parsed.scheme in ("https", "http", "mailto"): return None require(not parsed.scheme and not parsed.netloc and not target.startswith(("/", "\\")), f"{relative(root, source)}: absolute/external resource forbidden: {target}") name = unquote(parsed.path) require(bool(name) and "\\" not in name and ":" not in name and "\x00" not in name, f"{relative(root, source)}: invalid local resource: {target}") path = Path(os.path.abspath(source.parent / name)) require(path.is_relative_to(root), f"{relative(root, source)}: resource escapes project: {target}") return safe_path(root, relative(root, path), "source resource") def references(root: Path, source: Path) -> set[Path]: found = set() if source.suffix.lower() == ".svg": tree = ET.fromstring(source.read_text(encoding="utf-8")) targets = [value for element in tree.iter() for key, value in element.attrib.items() if key.rsplit("}", 1)[-1] == "href"] # SVG paint/filter/image CSS can reference additional local resources. targets += re.findall(r"url\(\s*['\"]?([^)'\"]+)['\"]?\s*\)", source.read_text(encoding="utf-8")) markdown = False elif source.suffix.lower() in (".md", ".markdown"): text = source.read_text(encoding="utf-8") targets = re.findall(r"!?\[[^\]\n]*\]\(\s*(<[^>]+>|[^\s)]+)(?:\s+[^)]*)?\)", text) targets += re.findall(r"^\s{0,3}\[[^\]\n]+\]:\s*(<[^>]+>|\S+)", text, re.MULTILINE) targets = [target[1:-1] if target.startswith("<") else target for target in targets] markdown = True else: return found for target in targets: path = resource_reference(root, source, target, markdown=markdown) if path is not None: found.add(path) return found def validate_json_data(path: Path) -> None: def unique(pairs): value = {} for key, item in pairs: require(key not in value, f"{path.name}: duplicate JSON key {key}") value[key] = item return value def invalid(value): raise ref.InputError(f"{path.name}: invalid JSON constant {value}") json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique, parse_constant=invalid) def author_inputs(root: Path, project: dict) -> tuple[set[Path], bool]: require(type(project.get("schema_version")) is int and project["schema_version"] == 1, "project.json: unsupported schema_version") ref.identifier(project.get("project_id"), "project_id") ref.string(project.get("title"), "title") _, _, manifest, _ = selection(root, project) environment = safe_path(root, project.get("environment"), "environment") env = ref.read_json(environment, (1,)) require(isinstance(env.get("python"), dict) and isinstance(env.get("packages"), dict) and isinstance(env.get("fonts"), list) and isinstance(env.get("tools"), list), "environment.json: expected python/packages objects and fonts/tools arrays") ref.string(env["python"].get("minimum"), "environment.python.minimum") paths = {safe_path(root, name, name) for name in ("project.json", *TOOLS, *METADATA)} | {environment} pages = project.get("pages") require(isinstance(pages, list), "pages: expected ordered array") ids = set() layouts = {layout["id"] for layout in manifest["layouts"]} for page in pages: require(isinstance(page, dict), "page: expected object") page_id = ref.identifier(page.get("id"), "page.id") require(page_id not in ids, f"Duplicate page ID: {page_id}") ids.add(page_id) layout = ref.identifier(page.get("layout"), f"page {page_id}.layout") require(layout in layouts, f"Undeclared layout: {layout}") for key, filename in (("source", "source.svg"), ("content", "content.json"), ("notes", "notes.md")): require(page.get(key) == f"pages/{page_id}/{filename}", f"page {page_id}.{key}: expected pages/{page_id}/{filename}") paths.add(safe_path(root, page[key], f"page {page_id}.{key}")) for key in ("data", "assets"): for name in ref.strings(page.get(key), f"page {page_id}.{key}"): paths.add(safe_path(root, name, f"page {page_id}.{key}")) build = project.get("build") require(isinstance(build, dict), "build: expected object") output = safe_path(root, build.get("output"), "build.output", exists=False) require(output.suffix.lower() == ".pptx" and relative(root, output).startswith("exports/"), "build.output: expected exports/*.pptx") entrypoint = build.get("entrypoint") if entrypoint is not None: entry = safe_path(root, entrypoint, "build.entrypoint") require(entry.suffix == ".py", "build.entrypoint: expected project Python script") paths.add(entry) for name in ref.strings(build.get("inputs"), "build.inputs"): paths.add(safe_path(root, name, "build.inputs")) # Traverse local SVG/Markdown resources so a present but undeclared image is # still locked and carried into the portable bundle. pending = list(paths) while pending: source = pending.pop() if source.suffix.lower() == ".json": validate_json_data(source) # Vendor documentation is byte-locked attribution, not a runtime import # graph. Its workflow links may intentionally be outside this snapshot. dependencies = () if source.suffix.lower() == '.md' and relative(root, source).startswith('vendor/') else references(root, source) for dependency in dependencies: if dependency not in paths: paths.add(dependency) pending.append(dependency) for path in paths: parts = PurePosixPath(relative(root, path)).parts require(not any(part in EXCLUDED for part in parts) and path.name != "environment.local.json", f"Author input uses a derived/local path: {relative(root, path)}") require(output not in paths, "build.output cannot be an author input") return paths, bool(pages) and entrypoint is not None def project_snapshot(root: Path, project: dict) -> tuple[dict, bool]: paths, ready = author_inputs(root, project) return {"schema_version": 1, "project_id": project["project_id"], "inputs": hashes(root, paths)}, ready def check(root: Path, *, author_lock: bool = True) -> tuple[dict, dict]: project = ref.read_json(safe_path(root, "project.json", "project.json"), (1,)) verify_template(root, project) snapshot, ready = project_snapshot(root, project) if author_lock: locked = ref.read_json(safe_path(root, "project.lock.json", "project lock"), (1,)) require(locked == snapshot, "Author drift: update intentional source changes with lock before check/build/pack") return project, {"ok": True, "project_id": project["project_id"], "build_ready": ready, "author_inputs": len(snapshot["inputs"]), "verification_scope": SCOPE} def init(args: argparse.Namespace) -> dict: root = root_path(args.project) require(not root.exists() or (root.is_dir() and not any(root.iterdir())), "init: project directory must be absent or empty") manifest = Path(os.path.abspath(args.manifest)) require(not any(p.is_symlink() for p in (manifest, *manifest.parents)), "Template source: symlink forbidden") require(not root.is_relative_to(manifest.parent) and not manifest.parent.is_relative_to(root), "init: project and template source must not overlap") ref.identifier(args.project_id, "project_id") ref.string(args.title, "title") root.parent.mkdir(parents=True, exist_ok=True) staging = Path(tempfile.mkdtemp(prefix=f".{root.name}-", dir=root.parent)) try: vendor = staging / "vendor/template" vendor.mkdir(parents=True) for source in sorted(tree_files(manifest.parent)): target = vendor / source.relative_to(manifest.parent) target.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(source, target) canonical_manifest = vendor / "manifest.json" if manifest.name != "manifest.json": require(not canonical_manifest.exists() or canonical_manifest.read_bytes() == manifest.read_bytes(), "init: manifest.json conflicts with selected manifest") shutil.copyfile(manifest, canonical_manifest) for folder in ("tools", "pages", "assets", "data"): (staging / folder).mkdir() for name in ("template_ref.py", "ppt_project.py"): shutil.copyfile(Path(__file__).resolve().with_name(name), staging / "tools" / name) project = {"schema_version": 1, "project_id": args.project_id, "title": args.title, "template": {"manifest": "vendor/template/manifest.json", "theme": args.theme, "lock": "template.lock.json"}, "authoring": {"route": args.route, "workflow": args.workflow}, "environment": "environment.json", "pages": [], "build": {"entrypoint": None, "output": "exports/deck.pptx", "inputs": []}} write_json(staging / "project.json", project) write_json(staging / "environment.json", {"schema_version": 1, "python": {"minimum": "3.10"}, "packages": {}, "fonts": [], "tools": []}) (staging / ".gitignore").write_text("build/\nexports/\n.venv/\nnode_modules/\n__pycache__/\n*.pyc\nenvironment.local.json\n", encoding="utf-8") (staging / ".gitattributes").write_text("* text=auto\n*.json text eol=lf working-tree-encoding=UTF-8\n*.py text eol=lf working-tree-encoding=UTF-8\n*.md text eol=lf working-tree-encoding=UTF-8\n*.svg text eol=lf working-tree-encoding=UTF-8\n*.csv text eol=lf working-tree-encoding=UTF-8\n*.tsv text eol=lf working-tree-encoding=UTF-8\n*.pptx binary\n*.pdf binary\n*.png binary\n*.jpg binary\n*.jpeg binary\n", encoding="utf-8") (staging / "README.md").write_text("# PPT source project\n\nPython 3.10+ is required. Edit project.json and stable pages// source.svg, content.json, notes.md, data and assets. Page order follows the pages array. Declare the local Python builder and every builder dependency in build.inputs. This initial skeleton has no pages or builder and cannot produce a deck.\n\nAfter intentional author edits: `python3 tools/ppt_project.py lock --project .`, then `check --project .`, then `build --project .`. lock never accepts changed template bytes. Keep project.json, both locks, environment.json, pages, data, assets, tools and vendor in version control; initialize Git separately if wanted. Review source/lock changes together.\n\nMove or unpack the whole project, provision the dependencies/fonts recorded in environment.json, then check and build again. resolve regenerates local absolute-path receipts under build/local; old receipts and exports are disposable. Pack with `python3 tools/ppt_project.py pack --project . --out /path/outside/project/source.zip`. The ZIP and SHA-256 receipt are never overwritten.\n\nThe builder is explicitly invoked project Python code: review it before build. check and resolve do not execute it. Record Python/package versions, required tools, fonts and licensing in environment.json; machine-local settings belong in environment.local.json. Font availability/licenses, rendering, PowerPoint editing and projection need separate verification on the destination device.\n", encoding="utf-8") write_json(staging / "template.lock.json", template_snapshot(staging, project)) snapshot, _ = project_snapshot(staging, project) write_json(staging / "project.lock.json", snapshot) check(staging) # rename fails if a concurrent writer populated the target; never merge. os.rename(staging, root) finally: if staging.exists(): shutil.rmtree(staging) return check(root)[1] def resolve(root: Path) -> dict: project, report = check(root) receipt, manifest, value, args = selection(root, project) local = safe_path(root, "build/local/receipt.json", "runtime receipt", exists=False).parent local.mkdir(parents=True, exist_ok=True) run_id = uuid.uuid4().hex selections = [] styles = [] for page in project["pages"] or [None]: args.layout = page["layout"] if page else None name = page["id"] if page else "project" receipt = ref.resolve(manifest, value, args) selection_path = local / f"{run_id}-{name}-selection.json" write_json(selection_path, receipt) selections.append(str(selection_path)) if value["schema_version"] == 2: style_path = local / f"{run_id}-{name}-style.json" write_json(style_path, ref.export_style(manifest, value, args)) styles.append(str(style_path)) return {**report, "selection_receipts": selections, "style_receipts": styles} def validate_pptx(path: Path, pages: int) -> None: require(path.is_file() and not path.is_symlink(), "Builder produced no regular PPTX file") with zipfile.ZipFile(path) as archive: names = archive.namelist() require(len(names) == len(set(names)), "PPTX contains duplicate ZIP members") require(all(not PurePosixPath(name).is_absolute() and ".." not in PurePosixPath(name).parts and "\\" not in name for name in names), "PPTX contains unsafe ZIP members") required = {"[Content_Types].xml", "ppt/presentation.xml", "ppt/_rels/presentation.xml.rels"} require(required <= set(names), "Incomplete PPTX ZIP members") expected = {"[Content_Types].xml": "{http://schemas.openxmlformats.org/package/2006/content-types}Types", "ppt/presentation.xml": "{http://schemas.openxmlformats.org/presentationml/2006/main}presentation", "ppt/_rels/presentation.xml.rels": "{http://schemas.openxmlformats.org/package/2006/relationships}Relationships"} for name, tag in expected.items(): require(ET.fromstring(archive.read(name)).tag == tag, f"PPTX invalid XML root: {name}") slides = [name for name in names if re.fullmatch(r"ppt/slides/slide[0-9]+\.xml", name)] require(len(slides) == pages, "PPTX slide count differs from project pages") presentation = ET.fromstring(archive.read("ppt/presentation.xml")) ns = "{http://schemas.openxmlformats.org/presentationml/2006/main}" require(len(presentation.findall(f"{ns}sldIdLst/{ns}sldId")) == pages, "PPTX presentation slide list differs from project pages") for name in slides: require(ET.fromstring(archive.read(name)).tag == ns + "sld", f"PPTX invalid slide XML: {name}") require(archive.testzip() is None, "PPTX ZIP CRC failure") def build(root: Path) -> dict: project, report = check(root) require(report["build_ready"], "Project is not build-ready: declare at least one page and a Python build.entrypoint") resolved = resolve(root) temporary = safe_path(root, f"build/tmp/{uuid.uuid4().hex}.pptx", "build temporary", exists=False) temporary.parent.mkdir(parents=True, exist_ok=True) output = safe_path(root, project["build"]["output"], "build.output", exists=False) try: result = subprocess.run([sys.executable, str(safe_path(root, project["build"]["entrypoint"], "builder")), "--project", str(root), "--output", str(temporary)], cwd=root, shell=False, capture_output=True, text=True, timeout=600) require(result.returncode == 0, f"Builder failed with exit code {result.returncode}; previous output preserved") validate_pptx(temporary, len(project["pages"])) check(root) # A builder must not mutate the source snapshot it claims to build. output = safe_path(root, project["build"]["output"], "build.output", exists=False) output.parent.mkdir(parents=True, exist_ok=True) os.replace(temporary, output) finally: temporary.unlink(missing_ok=True) return {**resolved, "output": str(output), "sha256": ref.digest(output)} def pack(root: Path, destination: Path) -> dict: project, report = check(root) out = root_path(destination) require(not out.is_relative_to(root), "pack --out must be outside the project") require(out.suffix.lower() == ".zip", "pack --out must end in .zip") receipt = out.with_suffix(out.suffix + ".sha256.json") require(not out.exists() and not receipt.exists(), "pack refuses to overwrite ZIP or receipt") paths, _ = author_inputs(root, project) paths |= tree_files(root / "vendor/template") paths |= {safe_path(root, name, name) for name in ("template.lock.json", "project.lock.json")} out.parent.mkdir(parents=True, exist_ok=True) with tempfile.NamedTemporaryFile(prefix=".ppt-pack-", dir=out.parent, delete=False) as stream: temporary = Path(stream.name) created = [] try: with zipfile.ZipFile(temporary, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: for path in sorted(paths, key=lambda p: relative(root, p)): safe_path(root, relative(root, path), "pack input") info = zipfile.ZipInfo(relative(root, path), (1980, 1, 1, 0, 0, 0)) info.compress_type = zipfile.ZIP_DEFLATED info.create_system = 3 info.external_attr = 0o100644 << 16 archive.writestr(info, path.read_bytes(), compress_type=zipfile.ZIP_DEFLATED, compresslevel=9) check(root) checksum = ref.digest(temporary) with out.open("xb") as target: created.append(out) with temporary.open("rb") as source: shutil.copyfileobj(source, target) value = {"schema_version": 1, "project_id": project["project_id"], "archive": out.name, "sha256": checksum, "files": len(paths), "verification_scope": SCOPE} with receipt.open("x", encoding="utf-8", newline="\n") as target: created.append(receipt) target.write(json.dumps(value, ensure_ascii=False, indent=2) + "\n") except Exception: for path in created: path.unlink(missing_ok=True) raise finally: temporary.unlink(missing_ok=True) return {**report, **value, "output": str(out), "receipt": str(receipt)} def main() -> int: parser = argparse.ArgumentParser(description=__doc__) commands = parser.add_subparsers(dest="command", required=True) for name in ("init", "check", "lock", "resolve", "build", "pack"): command = commands.add_parser(name) command.add_argument("--project", type=Path, required=True) if name == "init": command.add_argument("--manifest", type=Path, required=True) command.add_argument("--theme", required=True) command.add_argument("--title", required=True) command.add_argument("--project-id", default=str(uuid.uuid4())) command.add_argument("--route", choices=ref.ROUTES, default="native") command.add_argument("--workflow", choices=("interactive-page", "batch"), default="interactive-page") if name == "pack": command.add_argument("--out", type=Path, required=True) args = parser.parse_args() try: root = root_path(args.project) if args.command == "init": output = init(args) elif args.command == "check": output = check(root)[1] elif args.command == "lock": project, _ = check(root, author_lock=False) snapshot, _ = project_snapshot(root, project) atomic_json(safe_path(root, "project.lock.json", "project lock", exists=False), snapshot) output = check(root)[1] elif args.command == "resolve": output = resolve(root) elif args.command == "build": output = build(root) else: output = pack(root, args.out) print(json.dumps(output, ensure_ascii=False, indent=2)) return 0 except (ref.InputError, OSError, ValueError, ET.ParseError, zipfile.BadZipFile, subprocess.TimeoutExpired) as error: print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr) return 2 if __name__ == "__main__": raise SystemExit(main())