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
+178
View File
@@ -0,0 +1,178 @@
"""Apply only the external template's declared theme and typography to PPTX."""
from __future__ import annotations
from contextlib import contextmanager
from pathlib import Path
import re
import tempfile
import zipfile
from lxml import etree as ET
COLORS = ("dk1", "lt1", "dk2", "lt2", "accent1", "accent2", "accent3",
"accent4", "accent5", "accent6", "hlink", "folHlink")
NS = {"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main"}
@contextmanager
def preserve_declared_font_families(style: dict):
"""Keep declared families during one serial compiler call, then restore it.
The upstream platform map can erase the source font identity before OOXML
exists. An in-memory identity override avoids that lossy step for declared
template families only. The bundled source files are never modified. This
context is serial-process scoped; do not run other compiler calls in threads
while it is active. Unlisted source fonts retain upstream behavior.
"""
from svg_to_pptx.drawingml.utils import FONT_FALLBACK_WIN
families, *_ = typography(style)
names = {entry["family"] for entry in families.values()}
names.update(entry["cjk_fallback"] for entry in families.values() if entry.get("cjk_fallback"))
absent = object()
previous = {name: FONT_FALLBACK_WIN.get(name, absent) for name in names}
try:
for name in names:
FONT_FALLBACK_WIN[name] = name
yield
finally:
for name, value in previous.items():
if value is absent:
FONT_FALLBACK_WIN.pop(name, None)
else:
FONT_FALLBACK_WIN[name] = value
def typography(style):
"""Require author-declared families/sizes rather than supply default style."""
try:
families = style["typography"]["families"]
roles = style["typography"]["roles"]
title_role, body_role = roles["page_title"], roles["body"]
title = families[title_role["family_role"]]["family"]
body = families[body_role["family_role"]]["family"]
sizes = [float(title_role["size_pt"]), float(body_role["size_pt"])]
if not all(1 <= size <= 4000 for size in sizes):
raise ValueError("size outside PowerPoint range")
if not all(isinstance(value, str) and value.strip() for value in (title, body)):
raise ValueError("empty family")
except (KeyError, TypeError, ValueError) as error:
raise ValueError("Native export requires declared page_title/body family_role and size_pt") from error
return families, title, body, *(round(size * 100) for size in sizes)
def finalize(path: Path, theme: dict, style: dict):
from svg_to_pptx.drawingml.theme_fonts import (
ThemeFontFace, ThemeFontSpec, MasterTextStyleSpec,
apply_theme_font_spec, apply_master_text_style_spec,
)
families, title, body, title_hpt, body_hpt = typography(style)
palette = theme["palette"]
if not all(re.fullmatch(r"#[0-9a-fA-F]{6}", palette.get(key, "")) for key in COLORS):
raise ValueError("Native export requires the 12 declared PowerPoint palette entries")
face = lambda family: ThemeFontFace(latin=family, ea=family, cs=family)
declared = {}
for entry in families.values():
declared.setdefault(entry["family"], []).append(entry)
with tempfile.TemporaryDirectory(prefix="declared-theme-", dir=path.parent) as temporary:
root = Path(temporary)
with zipfile.ZipFile(path) as archive:
archive.extractall(root)
for part in (root / "ppt/theme").glob("theme*.xml"):
tree = ET.parse(str(part))
scheme = tree.find(".//a:clrScheme", NS)
if scheme is None:
raise ValueError("Compiler omitted the theme color scheme")
scheme.set("name", theme["theme_id"])
for key in COLORS:
parent = scheme.find("a:" + key, NS)
if parent is None:
raise ValueError(f"Compiler omitted palette entry: {key}")
for child in list(parent):
parent.remove(child)
ET.SubElement(parent, "{" + NS["a"] + "}srgbClr", val=palette[key][1:])
tree.write(str(part), encoding="utf-8", xml_declaration=True)
apply_theme_font_spec(root, ThemeFontSpec(major=face(title), minor=face(body),
major_family=title, minor_family=body))
apply_master_text_style_spec(root, MasterTextStyleSpec(title_hpt=title_hpt, body_hpt=body_hpt))
# Correct the compiler's generic East Asian fallback for runs whose Latin
# family identifies a declared role. Never replace arbitrary source fonts.
for part in (root / "ppt").rglob("*.xml"):
tree = ET.parse(str(part))
changed = False
for run in tree.findall(".//a:rPr", NS) + tree.findall(".//a:defRPr", NS):
latin = run.find("a:latin", NS)
if latin is None or latin.get("typeface") not in declared:
continue
family = latin.get("typeface")
entries = declared[family]
fallbacks = {entry.get("cjk_fallback", family) for entry in entries}
ea = run.find("a:ea", NS)
if ea is not None and len(fallbacks) == 1:
ea.set("typeface", next(iter(fallbacks)))
changed = True
# Weight is role metadata, not an inference that all titles bold.
weights = {entry.get("weight_number") for entry in entries}
if len(weights) == 1 and None not in weights:
run.set("b", "1" if next(iter(weights)) >= 600 else "0")
changed = True
if changed:
tree.write(str(part), encoding="utf-8", xml_declaration=True)
# A template's body size is its default at each nesting level. The author
# can explicitly assign other declared sizes to individual objects.
for part in (root / "ppt/slideMasters").glob("slideMaster*.xml"):
tree = ET.parse(str(part))
for kind in ("bodyStyle", "otherStyle"):
for run in tree.findall(".//p:" + kind + "//a:defRPr", NS):
run.set("sz", str(body_hpt))
tree.write(str(part), encoding="utf-8", xml_declaration=True)
target = root / "finalized.pptx"
with zipfile.ZipFile(target, "w", zipfile.ZIP_DEFLATED) as archive:
for part in sorted(root.rglob("*")):
if part.is_file() and part != target:
archive.write(part, part.relative_to(root).as_posix())
target.replace(path)
def verify(path: Path, expected_layouts: dict, expected_names: list[str], notes: dict):
from pptx import Presentation
deck = Presentation(path)
masters = list(deck.slide_masters)
layouts = [layout for master in masters for layout in master.slide_layouts]
if len(masters) != 1 or len(layouts) != len(expected_layouts):
raise ValueError("Native Master/Layout counts differ from the template roster")
if {layout.name for layout in layouts} != set(expected_layouts.values()):
raise ValueError("Native Layout names differ from the manifest")
visible = [slide.slide_layout.name for slide in deck.slides]
if visible != expected_names:
raise ValueError("Visible slides bind to unexpected native Layouts")
placeholders = 0
for slide in deck.slides:
for shape in slide.placeholders:
matching = slide.slide_layout.placeholders.get(idx=shape.placeholder_format.idx)
if matching is None or shape.placeholder_format.type != matching.placeholder_format.type:
raise ValueError("Slide placeholder does not bind to its Layout")
placeholders += 1
for index, text in notes.items():
actual = deck.slides[index].notes_slide.notes_text_frame.text
if actual.strip() != text.strip():
raise ValueError("Speaker notes differ from the declared source")
font_faces = set()
with zipfile.ZipFile(path) as archive:
for part in archive.namelist():
if re.fullmatch(r"ppt/slides/slide\d+\.xml", part):
tree = ET.fromstring(archive.read(part))
for run in tree.findall(".//a:r", NS):
if not (run.findtext("a:t", namespaces=NS) or "").strip():
continue
properties = run.find("a:rPr", NS)
if properties is not None:
for key in ("latin", "ea", "cs"):
face = properties.find("a:" + key, NS)
if face is not None:
font_faces.add(face.get("typeface"))
return {"slides": len(deck.slides), "masters": len(masters), "layouts": len(layouts),
"visible_layouts": visible, "placeholders": placeholders,
"notes_verified": len(notes), "text_font_faces": sorted(font_faces), "verification_scope":
"native structure, selected layout links and supplied notes; rendering and editing unverified"}
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Portable project builder: finalized SVG pages and Markdown speaker notes."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import sys
import tempfile
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--project", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
project = args.project.resolve()
sys.path.insert(0, str(project / "vendor/native-workflow/scripts"))
from native_export import export, prepare_svg, regular
import template_ref as ref
import ppt_project
config, _ = ppt_project.check(project)
ref.require(config["authoring"]["route"] == "native", "Native builder requires the native authoring route")
args.output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="native-pages-", dir=args.output.parent) as temporary:
temporary = Path(temporary)
sources, notes = [], {}
for page in config["pages"]:
source = regular(project / page["source"], "page source")
target = temporary / f"{page['id']}.svg"
root = prepare_svg(source, target, project)
ref.require(root.get("data-pptx-layout") == page["layout"], "Page SVG layout differs from project.json")
sources.append(target)
notes[target.stem] = (project / page["notes"]).read_text(encoding="utf-8")
facts = export(project / config["template"]["manifest"], config["template"]["theme"],
sources, args.output, notes, project)
print(json.dumps(facts, ensure_ascii=False))
if __name__ == "__main__":
main()
+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())
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Apply an external theme's whitelisted SVG fill/stroke bindings, locally."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import re
import sys
import xml.etree.ElementTree as ET
import template_ref as ref
from native_export import boundary_file, regular
def paint(manifest_path: Path, theme_id: str, role: str, output: Path):
manifest_path = regular(manifest_path, "manifest")
manifest = ref.load_manifest(manifest_path)
ref.require(manifest["schema_version"] == 2, "Paint application requires manifest schema 2")
ref.require(theme_id in manifest["themes"], "Unknown theme")
_, theme = ref.load_theme(manifest_path.parent, manifest["themes"][theme_id], theme_id)
asset = theme["assets"].get(role)
ref.require(asset is not None and "paint_contract" in asset, "Asset needs an explicit SVG paint contract")
root_path = manifest_path.parent
source = boundary_file(root_path, asset["path"], "SVG asset")
contract_path = boundary_file(root_path, asset["paint_contract"], "paint contract")
contract = ref.read_json(contract_path, (1,))
text = source.read_text(encoding="utf-8")
ref.require(not re.search(r"<!DOCTYPE|<!ENTITY", text, re.I), "SVG declarations/entities forbidden")
root = ET.fromstring(text)
ref.require(not any(node.tag.rsplit("}", 1)[-1] in ("style", "script", "foreignObject") for node in root.iter()),
"Material helper requires explicit attributes without stylesheets or active elements")
by_id = {}
for node in root.iter():
if node.get("id"):
ref.require(node.get("id") not in by_id, "SVG IDs must be unique")
by_id[node.get("id")] = node
targets = {(target["selector"], target["channel"]): target["palette_index"]
for binding in theme["asset_bindings"] if binding["asset"] == role for target in binding["targets"]}
changes = []
for rule in contract["rules"]:
selector, channel = rule["selector"], rule["channel"]
ref.require(selector[1:] in by_id, f"Paint selector missing: {selector}")
node = by_id[selector[1:]]
ref.require(not re.search(r"(?:^|;)\s*" + channel + r"\s*:", node.get("style", ""), re.I),
f"Inline style overrides target channel: {selector}/{channel}")
value = node.get(channel)
ref.require(value is not None, f"Target paint must be explicit, not inherited: {selector}/{channel}")
if rule["policy"] in ("fixed", "structural"):
ref.require(value.lower() == rule["value"].lower(), f"Source disagrees with fixed/structural paint: {selector}/{channel}")
else:
ref.require(value.lower() != "none", f"Palette target is unpainted: {selector}/{channel}")
color = theme["palette"][targets[(selector, channel)]]
node.set(channel, color)
changes.append({"selector": selector, "channel": channel, "from": value, "to": color})
ref.require(output.suffix.lower() == ".svg", "Output must end in .svg")
ref.require(not output.exists() and not output.is_symlink(), "Paint helper refuses to overwrite output")
ref.require(not any(path.is_symlink() for path in output.parents), "Output ancestors cannot be symlinks")
output.parent.mkdir(parents=True, exist_ok=True)
with output.open("xb") as stream:
ET.ElementTree(root).write(stream, encoding="utf-8", xml_declaration=True)
return {"asset": role, "theme": theme_id, "output": str(output), "paint_changes": changes,
"verification_scope": "explicit SVG channel transformation; rendered material appearance unverified"}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--manifest", type=Path, required=True)
parser.add_argument("--theme", required=True)
parser.add_argument("--asset", required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
try:
print(json.dumps(paint(args.manifest, args.theme, args.asset, args.output), ensure_ascii=False))
return 0
except (ref.InputError, OSError, ValueError, KeyError, TypeError, ET.ParseError) as error:
print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
+468
View File
@@ -0,0 +1,468 @@
#!/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())
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Add a bundled native builder to a clean initialized project, without relocking."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import shutil
import sys
import tempfile
import ppt_project as project_cli
import template_ref as ref
def prepare(root: Path):
root = project_cli.root_path(root)
config, _ = project_cli.check(root)
ref.require(config["authoring"]["route"] == "native", "prepare requires authoring.route=native")
ref.require(config["build"]["entrypoint"] is None and not config["build"]["inputs"], "prepare refuses an existing/custom builder")
environment = project_cli.safe_path(root, config["environment"], "environment")
old_environment = environment.read_bytes()
env = ref.read_json(environment, (1,))
ref.require(not env["packages"] and not env["fonts"] and not env["tools"], "prepare refuses to overwrite a configured environment")
manifest = ref.load_manifest(root / config["template"]["manifest"])
ref.require(manifest["schema_version"] == 2, "prepare requires manifest schema 2")
template_root = (root / config["template"]["manifest"]).parent
style = ref.read_json(ref.file_path(template_root, manifest["style"], "style"), None)
workflow = root / "vendor/native-workflow"
builder = root / "tools/native_builder.py"
ref.require(not workflow.exists() and not builder.exists(), "prepare refuses existing native-workflow or native_builder.py paths")
skill = Path(__file__).resolve().parent.parent
provenance = ref.read_json(skill / "runtime/provenance.json", (1,))
package_lines = (skill / "runtime/requirements.txt").read_text(encoding="utf-8").splitlines()
packages = dict(line.split("==", 1) for line in package_lines if line.strip() and not line.startswith("#"))
fonts = [{"role": role, **entry, "distributed": False, "embedded": False,
"verification": "declared by the selected template; installation, file identity and licenses require destination readback"}
for role, entry in style["typography"]["families"].items()]
new_env = {"schema_version": 1, "python": {"minimum": "3.12", "project_cli_minimum": "3.10"},
"packages": packages, "fonts": fonts,
"tools": [{"name": provenance["component"], "version": provenance["version"],
"path": "vendor/native-workflow/runtime/ppt-master", "source": provenance["source"],
"license": provenance["license"], "upstream_modified": False}],
"external_executables": [], "capabilities": {"image_generation": "host tool when needed",
"PowerPoint": "host application and supported UI tool for application checks"}}
old_config = (root / "project.json").read_bytes()
created = []
with tempfile.TemporaryDirectory(prefix="native-prepare-", dir=root.parent) as temporary:
staging = Path(temporary) / "native-workflow"
staging.mkdir()
shutil.copytree(skill / "runtime", staging / "runtime", ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo"))
(staging / "scripts").mkdir()
for name in ("native_export.py", "finalize_theme.py", "template_ref.py"):
shutil.copyfile(skill / "scripts" / name, staging / "scripts" / name)
# The snapshot is an explicit build input; no installed Skill is needed
# after this project is moved to a different directory or machine.
inputs = ["vendor/native-workflow/" + path.relative_to(staging).as_posix()
for path in sorted(staging.rglob("*")) if path.is_file()]
try:
shutil.copytree(staging, workflow)
created.append(workflow)
with builder.open("xb") as target:
target.write((skill / "scripts/native_builder.py").read_bytes())
created.append(builder)
config["build"]["entrypoint"] = "tools/native_builder.py"
config["build"]["inputs"] = inputs
project_cli.atomic_json(environment, new_env)
project_cli.atomic_json(root / "project.json", config)
project_cli.check(root, author_lock=False)
except Exception:
environment.write_bytes(old_environment)
(root / "project.json").write_bytes(old_config)
for item in reversed(created):
shutil.rmtree(item) if item.is_dir() else item.unlink(missing_ok=True)
raise
return {"ok": True, "project": str(root), "builder": config["build"]["entrypoint"],
"copied_inputs": len(inputs), "author_lock_updated": False,
"next": "Review source and environment changes, add finalized page SVGs/notes, then run tools/ppt_project.py lock --project ."}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--project", type=Path, required=True)
args = parser.parse_args()
try:
print(json.dumps(prepare(args.project), ensure_ascii=False, indent=2))
return 0
except (ref.InputError, OSError, ValueError, KeyError, TypeError) as error:
print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
+430
View File
@@ -0,0 +1,430 @@
#!/usr/bin/env python3
"""Inspect, export style or bind one local presentation template. Standard library only."""
from __future__ import annotations
import argparse
from copy import deepcopy
import hashlib
import json
from pathlib import Path, PurePosixPath
import re
import sys
import zipfile
ID = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
VERSION = re.compile(
r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
r"(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)"
r"(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?"
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
)
COLOR = re.compile(r"^#[0-9A-Fa-f]{6}$")
PAINT_SELECTOR = re.compile(r"^#[A-Za-z_][A-Za-z0-9_.:-]*$")
ROUTES = ("native", "image-rebuild")
class InputError(ValueError):
pass
def require(condition: bool, message: str) -> None:
if not condition:
raise InputError(message)
def string(value: object, label: str) -> str:
require(isinstance(value, str) and bool(value.strip()), f"{label}: expected non-empty string")
return value
def identifier(value: object, label: str) -> str:
value = string(value, label)
require(bool(ID.fullmatch(value)), f"{label}: expected lowercase hyphenated ID")
return value
def strings(value: object, label: str, *, nonempty: bool = False) -> list[str]:
require(isinstance(value, list), f"{label}: expected string array")
for entry in value:
string(entry, label)
require(len(value) == len(set(value)), f"{label}: duplicate entries")
require(not nonempty or bool(value), f"{label}: must not be empty")
return value
def read_json(path: Path, schemas: tuple[int, ...] | None = (1, 2)) -> dict:
require(path.is_file(), f"Missing JSON file: {path}")
def unique_keys(pairs):
result = {}
for key, value in pairs:
require(key not in result, f"{path.name}: duplicate JSON key {key}")
result[key] = value
return result
def invalid_constant(value):
raise InputError(f"{path.name}: invalid JSON constant {value}")
value = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_keys,
parse_constant=invalid_constant)
require(isinstance(value, dict), f"{path.name}: expected object")
if schemas is not None:
require(type(value.get("schema_version")) is int and value["schema_version"] in schemas,
f"{path.name}: unsupported schema_version")
return value
def local_path(root: Path, value: object, label: str) -> Path:
raw = string(value, label)
relative = PurePosixPath(raw)
require(not relative.is_absolute() and ".." not in relative.parts
and "\\" not in raw and ":" not in raw and raw != ".",
f"{label}: expected package-relative POSIX path")
path = (root / raw).resolve()
require(path.is_relative_to(root.resolve()), f"{label}: path escapes package")
return path
def file_path(root: Path, value: object, label: str) -> Path:
path = local_path(root, value, label)
require(path.is_file(), f"{label}: missing file {path}")
return path
def load_paint_contract(root: Path, asset: dict, role: str) -> tuple[Path, dict]:
label = f"asset {role}.paint_contract"
asset_path = file_path(root, asset.get("path"), f"asset {role}.path")
require(asset_path.suffix.lower() == ".svg", f"{label}: only SVG assets support paint contracts")
path = file_path(root, asset.get("paint_contract"), label)
value = read_json(path, (1,))
string(value.get("asset_id"), f"{label}.asset_id")
require(value.get("default_policy") == "preserve", f"{label}.default_policy: expected preserve")
rules = value.get("rules")
require(isinstance(rules, list), f"{label}.rules: expected array")
ids, channels = set(), set()
for rule in rules:
require(isinstance(rule, dict), f"{label}.rule: expected object")
rule_id = string(rule.get("id"), f"{label}.rule.id")
require(rule_id not in ids, f"{label}: duplicate rule ID {rule_id}")
ids.add(rule_id)
selector = string(rule.get("selector"), f"{label}.rule.selector")
require(bool(PAINT_SELECTOR.fullmatch(selector)),
f"{label}.rule.selector: expected #stable-ID")
channel = rule.get("channel")
require(channel in ("fill", "stroke"), f"{label}.rule.channel: expected fill or stroke")
key = (selector, channel)
require(key not in channels, f"{label}: duplicate selector/channel {selector}/{channel}")
channels.add(key)
policy = rule.get("policy")
require(policy in ("palette", "fixed", "structural"), f"{label}.rule.policy: unknown policy")
if policy == "palette":
string(rule.get("token"), f"{label}.rule.token")
else:
require("token" not in rule, f"{label}.rule.token: only palette rules have tokens")
literal = string(rule.get("value"), f"{label}.rule.value")
if policy == "structural":
require(literal == "none", f"{label}.rule.value: structural requires none")
return path, value
def load_theme(root: Path, entry: dict, theme_id: str) -> tuple[Path, dict]:
path = file_path(root, entry.get("theme"), f"theme {theme_id}.theme")
value = read_json(path, (1,))
require(value.get("theme_id") == theme_id, f"theme {theme_id}: theme_id mismatch")
palette = value.get("palette")
require(isinstance(palette, dict) and bool(palette), "palette: expected non-empty object")
for index, color in palette.items():
string(index, "palette index")
require(isinstance(color, str) and bool(COLOR.fullmatch(color)),
f"palette {index}: expected #RRGGBB")
assets = value.get("assets")
require(isinstance(assets, dict), "assets: expected object")
contracts = {}
for role, asset in assets.items():
string(role, "asset role")
require(isinstance(asset, dict), f"asset {role}: expected object")
file_path(root, asset.get("path"), f"asset {role}.path")
if "paint_contract" in asset:
_, contract = load_paint_contract(root, asset, role)
contracts[role] = {(rule["selector"], rule["channel"]): rule for rule in contract["rules"]}
bindings = value.get("asset_bindings")
require(isinstance(bindings, list), "asset_bindings: expected array")
bound_channels = set()
for binding in bindings:
require(isinstance(binding, dict), "asset binding: expected object")
role = string(binding.get("asset"), "binding.asset")
require(role in assets, f"Unknown asset role: {role}")
targets = binding.get("targets")
require(isinstance(targets, list) and bool(targets), "binding.targets: expected non-empty array")
for target in targets:
require(isinstance(target, dict), "binding target: expected object")
string(target.get("selector"), "target.selector")
require(target.get("channel") in ("fill", "stroke"), "target.channel: expected fill or stroke")
index = string(target.get("palette_index"), "target.palette_index")
require(index in palette, f"Unknown palette index: {index}")
if role in contracts:
key = (target["selector"], target["channel"])
rule = contracts[role].get(key)
require(rule is not None and rule["policy"] == "palette",
f"asset {role}: target must match a palette paint rule: {key[0]}/{key[1]}")
bound = (role, *key)
require(bound not in bound_channels,
f"asset {role}: duplicate palette binding {key[0]}/{key[1]}")
bound_channels.add(bound)
for role, rules in contracts.items():
for (selector, channel), rule in rules.items():
if rule["policy"] == "palette":
require((role, selector, channel) in bound_channels,
f"asset {role}: missing palette binding {selector}/{channel}")
return path, value
def load_manifest(path: Path) -> dict:
value = read_json(path)
identifier(value.get("template_id"), "template_id")
require(bool(VERSION.fullmatch(string(value.get("version"), "version"))), "version: expected SemVer")
for key in ("name", "description"):
string(value.get(key), key)
require(value.get("status") in ("proposal", "approved", "released", "deprecated"), "Invalid status")
file_path(path.parent, value.get("spec"), "spec")
require("structure" in value, "Missing structure field")
if value["structure"] is not None:
file_path(path.parent, value["structure"], "structure")
v2 = value["schema_version"] == 2
if v2:
read_json(file_path(path.parent, value.get("style"), "style"), None)
variants_key = "themes" if v2 else "modes"
variants = value.get(variants_key)
require(isinstance(variants, dict) and bool(variants), f"{variants_key}: expected non-empty object")
for variant, entry in variants.items():
identifier(variant, f"{variants_key} ID")
require(isinstance(entry, dict), f"{variant}: expected object")
string(entry.get("name"), f"{variant}.name")
for key in ("workspace", "native_template"):
require(key in entry, f"{variant}: missing {key}")
if entry[key] is not None or (v2 and key == "workspace"):
local_path(path.parent, entry[key], f"{variant}.{key}")
if v2:
load_theme(path.parent, entry, variant)
layouts = value.get("layouts")
require(isinstance(layouts, list) and bool(layouts), "layouts: expected non-empty array")
seen = set()
for entry in layouts:
require(isinstance(entry, dict), "layout: expected object")
key = identifier(entry.get("id"), "layout ID")
require(key not in seen, f"Duplicate layout: {key}")
seen.add(key)
string(entry.get("name"), f"layout {key}.name")
strings(entry.get("slots"), f"layout {key}.slots")
routes = strings(value.get("routes"), "routes", nonempty=True)
require(all(route in ROUTES for route in routes), "Unsupported route")
policy = value.get("editable_policy")
require(isinstance(policy, dict), "editable_policy: expected object")
for key in ("native", "image_allowed", "disclose"):
strings(policy.get(key), f"editable_policy.{key}")
return value
def summary(path: Path, value: dict) -> dict:
# Preserve extension metadata for review; only declared paths are interpreted.
return {**deepcopy(value), "manifest": str(path),
"spec": str(file_path(path.parent, value["spec"], "spec"))}
def digest(path: Path) -> str:
with path.open("rb") as stream:
checksum = hashlib.sha256()
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
checksum.update(chunk)
return checksum.hexdigest()
def selected_variant(value: dict, args: argparse.Namespace) -> tuple[str, dict]:
v2 = value["schema_version"] == 2
key, other = ("theme", "mode") if v2 else ("mode", "theme")
require(getattr(args, other, None) is None, f"v{value['schema_version']} uses --{key}, not --{other}")
selected = getattr(args, key, None)
require(selected is not None, f"v{value['schema_version']} requires --{key}")
variants = value["themes" if v2 else "modes"]
require(selected in variants, f"Unknown {key}: {selected}")
return selected, variants[selected]
def selected_layout(value: dict, layout: str | None) -> dict | None:
if layout is None:
return None
result = next((entry for entry in value["layouts"] if entry["id"] == layout), None)
require(result is not None, f"Unknown layout: {layout}")
return result
def workspace_inputs(root: Path, workspace: Path) -> set[Path]:
inputs = set()
# Exports are derived evidence. Hash the adapter's actual authoring surfaces.
for folder in ("templates", "images", "icons"):
source = workspace / folder
if not source.exists():
continue
require(source.resolve().is_relative_to(root), f"{folder}: path escapes package")
for candidate in source.rglob("*"):
resolved = candidate.resolve()
require(resolved.is_relative_to(root), f"{candidate.name}: path escapes package")
if resolved.is_file():
inputs.add(resolved)
return inputs
def input_hashes(root: Path, inputs: set[Path]) -> list[dict]:
return [{"path": str(item.relative_to(root)), "sha256": digest(item)} for item in sorted(inputs)]
def style_inputs(root: Path, value: dict, variant: dict, selected: str) -> tuple[Path, dict, Path, dict, set[Path]]:
style_path = file_path(root, value["style"], "style")
style = read_json(style_path, None)
theme_path, theme = load_theme(root, variant, selected)
inputs = {style_path, theme_path}
theme = deepcopy(theme)
for role, asset in theme["assets"].items():
asset_path = file_path(root, asset["path"], f"asset {role}.path")
inputs.add(asset_path)
if "paint_contract" in asset:
contract_path, contract = load_paint_contract(root, asset, role)
inputs.add(contract_path)
asset["paint_contract"] = str(contract_path)
asset["paint_contract_data"] = contract
asset["path"] = str(asset_path)
return style_path, style, theme_path, theme, inputs
def export_style(path: Path, value: dict, args: argparse.Namespace) -> dict:
require(value["schema_version"] == 2, "export-style requires a v2 manifest")
selected, variant = selected_variant(value, args)
layout = selected_layout(value, args.layout)
root = path.parent
style_path, style, theme_path, theme, inputs = style_inputs(root, value, variant, selected)
spec = file_path(root, value["spec"], "spec")
structure = file_path(root, value["structure"], "structure") if value["structure"] is not None else None
workspace = local_path(root, variant["workspace"], "workspace")
native = local_path(root, variant["native_template"], "native_template") if variant["native_template"] is not None else None
inputs.update({path, spec})
if structure is not None:
inputs.add(structure)
inputs.update(workspace_inputs(root, workspace))
if native is not None and native.is_file():
inputs.add(native)
return {"schema_version": 2, "template_id": value["template_id"], "version": value["version"],
"status": value["status"], "production_ready": value["status"] == "released",
"manifest": str(path), "manifest_sha256": digest(path), "theme": selected,
"layout": args.layout, "layout_definition": deepcopy(layout),
"spec": str(spec), "structure": str(structure) if structure is not None else None,
"workspace": str(workspace), "native_template": str(native) if native is not None else None,
"style_path": str(style_path), "theme_path": str(theme_path),
"style": style, "theme_data": theme, "editable_policy": deepcopy(value["editable_policy"]),
"inputs": input_hashes(root, inputs),
"verification_scope": "style review and alignment only; no PPT creation, selector execution or release acceptance"}
def resolve(path: Path, value: dict, args: argparse.Namespace) -> dict:
require(value["status"] == "released", f"NOT_RELEASED: status={value['status']}; inspect or export-style only")
selected, variant = selected_variant(value, args)
require(args.route in value["routes"], f"Route unavailable: {args.route}")
selected_layout(value, args.layout)
root = path.parent
structure = file_path(root, value["structure"], "structure")
spec = file_path(root, value["spec"], "spec")
workspace = local_path(root, variant["workspace"], "workspace")
require(workspace.is_dir(), f"Missing workspace: {workspace}")
file_path(workspace, "templates/design_spec.md", "workspace specification")
require(bool(list((workspace / "templates").glob("*.svg"))), "Workspace has no SVG prototypes")
native = None
v2 = value["schema_version"] == 2
if variant["native_template"] is not None or not v2:
native = file_path(root, variant["native_template"], "native_template")
require(native.suffix.lower() == ".pptx", "native_template must be .pptx")
require("exports" not in native.relative_to(root).parts,
"native_template cannot use an exports review artifact")
with zipfile.ZipFile(native) as package:
require({"[Content_Types].xml", "ppt/presentation.xml", "ppt/_rels/presentation.xml.rels"}
<= set(package.namelist()), "Incomplete PPTX ZIP members")
inputs = {path, structure, spec} | workspace_inputs(root, workspace)
if native is not None:
inputs.add(native)
result = {"schema_version": value["schema_version"], "template_id": value["template_id"],
"version": value["version"], "manifest": str(path), "manifest_sha256": digest(path),
"theme" if v2 else "mode": selected, "layout": args.layout, "route": args.route,
"workflow": args.workflow, "workspace": str(workspace),
"native_template": str(native) if native is not None else None,
"spec": str(spec), "structure": str(structure),
"verification_scope": "metadata, local paths, input hashes, workspace shape and declared PPTX ZIP members only"}
if v2:
style_path, _, theme_path, _, theme_files = style_inputs(root, value, variant, selected)
inputs.update(theme_files)
result.update(status=value["status"], production_ready=True,
style_path=str(style_path), theme_path=str(theme_path))
result["inputs"] = input_hashes(root, inputs)
return result
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
listing = sub.add_parser("list")
listing.add_argument("--catalog", type=Path, required=True)
for command in ("inspect", "resolve", "export-style"):
item = sub.add_parser(command)
item.add_argument("--manifest", type=Path, required=True, action="append")
if command != "inspect":
selection = item.add_mutually_exclusive_group(required=True)
selection.add_argument("--mode", action="append")
selection.add_argument("--theme", action="append")
item.add_argument("--layout", action="append")
item.add_argument("--out", type=Path)
if command == "resolve":
item.add_argument("--route", choices=ROUTES, required=True)
item.add_argument("--workflow", choices=("interactive-page", "batch"), required=True)
args = parser.parse_args()
try:
if hasattr(args, "manifest"):
require(len(args.manifest) == 1, "Only one --manifest may be active")
args.manifest = args.manifest[0]
# Reject repeated selectors instead of argparse's usual last-value-wins behavior.
for key in ("mode", "theme", "layout"):
selections = getattr(args, key, None)
if selections is not None:
require(len(selections) == 1, f"Only one --{key} may be active")
setattr(args, key, selections[0])
if args.command == "list":
catalog = args.catalog.resolve()
entries = read_json(catalog).get("templates")
require(isinstance(entries, list), "templates: expected array")
output, seen = [], set()
for entry in entries:
require(isinstance(entry, dict), "catalog entry: expected object")
path = file_path(catalog.parent, entry.get("manifest"), "manifest")
value = load_manifest(path)
require(value["template_id"] not in seen, f"Duplicate template: {value['template_id']}")
seen.add(value["template_id"])
output.append(summary(path, value))
else:
path = args.manifest.resolve()
value = load_manifest(path)
if args.command == "inspect":
output = summary(path, value)
elif args.command == "export-style":
output = export_style(path, value, args)
else:
output = resolve(path, value, args)
encoded = json.dumps(output, ensure_ascii=False, indent=2) + "\n"
if args.command in ("resolve", "export-style") and args.out is not None:
with args.out.open("x", encoding="utf-8", newline="\n") as stream:
stream.write(encoded)
print(encoded, end="")
return 0
except (InputError, OSError, ValueError, zipfile.BadZipFile) as error:
print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())