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

170 lines
9.5 KiB
Python

#!/usr/bin/env python3
"""Discover and verify optional template page examples; never write or activate a template."""
from __future__ import annotations
import argparse
from copy import deepcopy
import json
from pathlib import Path
import re
import sys
from urllib.parse import unquote, urlsplit
from xml.etree import ElementTree as ET
from template_ref import (InputError, VERSION, digest, file_path, identifier, input_hashes,
load_manifest, read_json, require, string, strings)
def checked_file(root: Path, record: object, label: str) -> Path:
require(isinstance(record, dict), f"{label}: expected path/sha256 object")
path = file_path(root, record.get("path"), label)
checksum = string(record.get("sha256"), f"{label}.sha256")
require(bool(re.fullmatch(r"[0-9a-f]{64}", checksum)), f"{label}: invalid SHA-256")
require(digest(path) == checksum, f"{label}: SHA-256 mismatch: {record['path']}")
return path
def check_source(source: Path, declared: set[Path]) -> None:
text = source.read_text(encoding="utf-8")
require("<!DOCTYPE" not in text.upper() and "<!ENTITY" not in text.upper(),
"library SVG: document entities are unsupported")
svg = ET.fromstring(text)
require(svg.tag.rsplit("}", 1)[-1] == "svg", "library source must be SVG")
for node in svg.iter():
for key, href in node.attrib.items():
if key.rsplit("}", 1)[-1] != "href" or href.startswith("#"):
continue
if href.startswith(("data:image/png;base64,", "data:image/jpeg;base64,")):
continue
uri = urlsplit(href)
require(not uri.scheme and not uri.netloc and not uri.query,
f"library SVG: external resource {href}")
raw = unquote(uri.path)
require(raw and not Path(raw).is_absolute() and "\\" not in raw,
f"library SVG: invalid resource {href}")
resolved = (source.parent / raw).resolve()
require(resolved in declared, f"library SVG: undeclared resource {href}")
def load_library(manifest_path: Path) -> tuple[dict, Path | None, dict | None]:
manifest = load_manifest(manifest_path)
if "page_library" not in manifest:
return manifest, None, None
root = manifest_path.parent
path = file_path(root, manifest["page_library"], "page_library")
catalog = read_json(path, (1,))
require(catalog.get("template_id") == manifest["template_id"], "library template_id mismatch")
entries = catalog.get("entries")
require(isinstance(entries, list), "library entries: expected array")
seen = set()
for entry in entries:
require(isinstance(entry, dict), "library entry: expected object")
key = identifier(entry.get("id"), "library entry ID")
require(key not in seen, f"Duplicate library entry: {key}")
seen.add(key)
string(entry.get("title"), "entry.title")
require(entry.get("status") in ("candidate", "reference", "deprecated"), "entry.status invalid")
versions = strings(entry.get("template_versions"), "entry.template_versions", nonempty=True)
require(all(VERSION.fullmatch(v) for v in versions), "entry.template_versions: expected exact SemVer values")
require(entry.get("layout") in {item["id"] for item in manifest["layouts"]}, "Unknown library layout")
strings(entry.get("tags"), "entry.tags")
checked_file(root, entry.get("design"), f"{key}.design")
string(entry.get("origin"), f"{key}.origin")
variants = entry.get("variants")
require(isinstance(variants, dict) and bool(variants), f"{key}.variants: expected non-empty object")
for theme, variant in variants.items():
require(theme in manifest.get("themes", manifest.get("modes", {})), f"Unknown library theme: {theme}")
require(isinstance(variant, dict), f"{key}.{theme}: expected object")
files = variant.get("files")
require(isinstance(files, list) and bool(files), f"{key}.{theme}.files: expected non-empty array")
paths = [checked_file(root, item, f"{key}.{theme}.files") for item in files]
require(len(paths) == len(set(paths)), f"{key}.{theme}: duplicate files")
source = file_path(root, variant.get("source"), "variant.source")
preview = file_path(root, variant.get("preview"), "variant.preview")
require(source in paths and preview in paths, "source and preview must be declared files")
require(source.suffix.lower() == ".svg", "library source must be .svg")
require(preview.suffix.lower() in (".png", ".jpg", ".jpeg"), "library preview must be PNG/JPEG")
evidence = variant.get("evidence")
require(isinstance(evidence, dict), "variant.evidence: expected object")
require(evidence.get("level") in ("source-only", "svg-rendered", "powerpoint-rendered", "powerpoint-edited"),
"variant.evidence.level invalid")
string(evidence.get("source_version"), "evidence.source_version")
string(evidence.get("notes"), "evidence.notes")
check_source(source, set(paths))
return manifest, path, catalog
def inspect_entry(manifest_path: Path, manifest: dict, path: Path, entry: dict,
theme: str, for_use: bool) -> dict:
compatible = manifest["version"] in entry["template_versions"]
eligible = entry["status"] == "reference" and compatible and theme in entry["variants"]
if for_use:
require(manifest["status"] == "released", "Library reuse requires a released template")
require(eligible, "Entry is not a compatible reference for the selected theme")
require(theme in entry["variants"], f"Entry has no variant for theme {theme}")
root = manifest_path.parent
variant = deepcopy(entry["variants"][theme])
inputs = {manifest_path, path, file_path(root, entry["design"]["path"], "design")}
inputs.update(file_path(root, item["path"], "variant file") for item in variant["files"])
return {"template_id": manifest["template_id"], "template_version": manifest["version"],
"entry_id": entry["id"], "title": entry["title"], "status": entry["status"],
"layout": entry["layout"], "theme": theme, "compatible": compatible, "eligible": eligible,
"design": str(file_path(root, entry["design"]["path"], "design")),
"source": str(file_path(root, variant["source"], "source")),
"preview": str(file_path(root, variant["preview"], "preview")),
"variant": variant, "inputs": input_hashes(root, inputs),
"verification_scope": "declared metadata, file hashes and SVG href closure; preview provenance is declared, not rerendered"}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
for name in ("list", "check", "inspect"):
item = sub.add_parser(name)
item.add_argument("--manifest", type=Path, required=True, action="append")
if name == "list":
item.add_argument("--theme", action="append")
item.add_argument("--layout", action="append")
if name == "inspect":
item.add_argument("--entry", required=True, action="append")
item.add_argument("--theme", required=True, action="append")
item.add_argument("--for-use", action="store_true")
args = parser.parse_args()
try:
for key in ("manifest", "theme", "layout", "entry"):
values = getattr(args, key, None)
if values is not None:
require(len(values) == 1, f"Only one --{key} may be active")
setattr(args, key, values[0])
manifest_path = args.manifest.resolve()
manifest, path, catalog = load_library(manifest_path)
if args.command == "inspect":
require(catalog is not None, "Template has no page library")
entry = next((item for item in catalog["entries"] if item["id"] == args.entry), None)
require(entry is not None, f"Unknown library entry: {args.entry}")
output = inspect_entry(manifest_path, manifest, path, entry, args.theme, args.for_use)
else:
entries = [] if catalog is None else catalog["entries"]
theme, layout = getattr(args, "theme", None), getattr(args, "layout", None)
if theme:
require(theme in manifest.get("themes", manifest.get("modes", {})), "Unknown theme filter")
if layout:
require(layout in {item["id"] for item in manifest["layouts"]}, "Unknown layout filter")
entries = [{"id": entry["id"], "title": entry["title"], "status": entry["status"],
"layout": entry["layout"], "themes": list(entry["variants"]), "tags": entry["tags"],
"compatible": manifest["version"] in entry["template_versions"]}
for entry in entries if (not theme or theme in entry["variants"])
and (not layout or layout == entry["layout"])]
output = {"template_id": manifest["template_id"], "available": catalog is not None,
"catalog": str(path) if path else None, "entries": entries, "count": len(entries)}
print(json.dumps(output, ensure_ascii=False, indent=2))
return 0
except (InputError, OSError, ValueError, ET.ParseError) as error:
print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())