Files
Templated-PPT-Master/scripts/ppt_project.py
T

469 lines
26 KiB
Python

#!/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/<id>/ 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())