Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -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"}
|
||||
@@ -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()
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user