$ cat node-template.py
P
PPTX Builder
// Builds a PowerPoint file from a composed library presentation by filling the customer's own master: each slide uses its layout from the layout library, with text, bullets, tables, pictures and speaker notes in the layout's placeholders. Every slide is checked against the library first, and the run fails listing every problem by slide and slot. Outputs the .pptx, named after the presentation title, plus PNG previews (optional: if they cannot be rendered the .pptx is still ready).
Output
Document
#presentation#pptx#powerpoint#slides#brand#layout#library#export
template.py
1"""pptx-builder — fill the customer's PowerPoint master from a library deck.23Design: docs/designs/brand-master-layout-libraries.md (PR 24`node-templates/pptx-builder/`, design 6A and 11A, eng review R4 A and R8 A).56Inputs come over edges only: the Composer's `presentation` HTML (pictures are7base64 data URIs inside it), and the Layout Library's `library` handle and8`master` file. This node holds no user token and makes no library call (R4 A).910Run:11 1. `gais.slides.fill_pptx` re-validates every `<section data-library12 data-layout>` against the handle's catalog, collects every problem and13 raises ONE LibraryError, DECK_INVALID{problems: [{code, params}], slides}14 (FOREIGN_SECTION, LAYOUT_NOT_IN_LIBRARY, UNPLACEABLE_ELEMENT,15 SLOT_OVER_CAPACITY, …); otherwise it writes the .pptx, named after the16 Composer's <title>.17 2. Previews are optional (design 11A): `gais.render.pptx_to_png`18 (document-render) draws one PNG per slide. If that fails for any reason19 the node still completes, `previews` is empty and `previews_error` holds20 the library error, so the UI can say the .pptx is ready.2122Each gais call sits in one small adapter below.23"""2425from __future__ import annotations2627import html28import json29import re30import sys31import traceback32from typing import Any3334from gais import Gais35from gais.slides import LibraryError3637INPUT_DIR = "/data/input"38OUTPUT_DIR = "/data/output"39DEFAULT_TITLE = "presentation"4041_TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title\s*>", re.IGNORECASE | re.DOTALL)42# Characters a file name may not carry through the workspace file routes:43# path and Windows-reserved characters, `%` (keys are URL-decoded before44# their traversal check), `#` and `?` (URL fragment and query), controls.45_UNSAFE_NAME_CHARS = re.compile(r'[\x00-\x1f\x7f<>:"/\\|?*%#]')46_MAX_NAME_CHARS = 120474849# ── gais adapters ───────────────────────────────────────────────────────────505152def _fill(master_path: str, catalog: dict[str, Any], presentation: str, out_path: str,53 library_id: str) -> dict[str, Any]:54 return Gais.slides.fill_pptx(master_path, catalog, presentation, out_path, library_id=library_id)555657def _render_pngs(pptx_path: str) -> list[str]:58 """document-render: one PNG per slide, in slide order."""59 return [str(p) for p in Gais.render.pptx_to_png(pptx_path).data["pngs"]]606162# ── inputs ──────────────────────────────────────────────────────────────────636465def _presentation(raw: Any) -> str:66 if not isinstance(raw, str) or not raw.strip():67 raise ValueError(68 "Required input 'presentation' not provided: connect the Presentation Composer's output"69 )70 return raw717273def _handle(raw: Any) -> dict[str, Any]:74 """The Layout Library handle, as an object (a JSON string is accepted)."""75 if isinstance(raw, str) and raw.strip():76 try:77 raw = json.loads(raw)78 except json.JSONDecodeError:79 raw = None80 catalog = raw.get("catalog") if isinstance(raw, dict) else None81 library_id = raw.get("libraryId") if isinstance(raw, dict) else None82 if (83 not isinstance(catalog, dict)84 or not isinstance(catalog.get("layouts"), list)85 or not isinstance(library_id, str)86 or not library_id87 ):88 raise ValueError(89 "Input 'library' is not a Layout Library handle: connect the Layout Library node's library output"90 )91 return raw929394def _input_file(raw: Any) -> str:95 """Upstream file ports deliver a bare file name under /data/input."""96 name = raw.replace("\\", "/").rsplit("/", 1)[-1].strip() if isinstance(raw, str) else ""97 if not name or name in (".", ".."):98 raise ValueError(99 "Required input 'master' not provided: connect the Layout Library node's master output"100 )101 return f"{INPUT_DIR}/{name}"102103104def _title(presentation: str) -> str:105 """The Composer's <title> (the head's, not one inside a slide's SVG)."""106 body_at = presentation.lower().find("<body")107 head = presentation if body_at < 0 else presentation[:body_at]108 match = _TITLE_RE.search(head)109 return html.unescape(match.group(1)).strip() if match else ""110111112def _pptx_name(title: str) -> str:113 name = _UNSAFE_NAME_CHARS.sub(" ", title)114 name = re.sub(r"\s+", " ", name).lstrip(". ").rstrip()115 name = name[:_MAX_NAME_CHARS].rstrip(" .")116 return f"{name or DEFAULT_TITLE}.pptx"117118119def _copy_file(src: str, dst: str) -> None:120 with open(src, "rb") as fin, open(dst, "wb") as fout:121 while chunk := fin.read(1024 * 1024):122 fout.write(chunk)123124125# ── run ─────────────────────────────────────────────────────────────────────126127128def _previews(pptx_path: str) -> tuple[list[str], str]:129 """(preview file names, previews_error). Never raises (design 11A)."""130 try:131 pngs = _render_pngs(pptx_path)132 names = []133 for n, png in enumerate(pngs, start=1):134 name = f"preview-{n:03d}.png"135 _copy_file(png, f"{OUTPUT_DIR}/{name}")136 names.append(name)137 return names, ""138 except Exception as e: # noqa: BLE001 — previews are best-effort by contract139 print(f"[pptx-builder] previews unavailable: {e}", file=sys.stderr)140 error = e if isinstance(e, LibraryError) else LibraryError("RENDER_SERVICE_UNAVAILABLE")141 return [], str(error)142143144def process(inputs: dict[str, Any]) -> dict[str, Any]:145 presentation = _presentation(inputs.get("presentation"))146 handle = _handle(inputs.get("library"))147 master_path = _input_file(inputs.get("master"))148149 pptx_name = _pptx_name(_title(presentation))150 pptx_path = f"{OUTPUT_DIR}/{pptx_name}"151 report = _fill(master_path, handle["catalog"], presentation, pptx_path, handle["libraryId"])152 print(f"[pptx-builder] built {pptx_name}: {json.dumps(report, default=str)[:500]}", file=sys.stderr)153154 previews, previews_error = _previews(pptx_path)155 return {156 "pptx": pptx_name,157 "previews": previews,158 "previews_error": previews_error,159 }160161162def main() -> None:163 try:164 envelope = json.loads(sys.stdin.read() or "{}")165 if not isinstance(envelope, dict):166 envelope = {}167 inputs = envelope.get("inputs") or {}168 json.dump(process(inputs), sys.stdout)169 except Exception as e:170 print(171 json.dumps({172 "error": str(e),173 "errorType": type(e).__name__,174 "traceback": traceback.format_exc(),175 }),176 file=sys.stderr,177 )178 sys.exit(1)179180181if __name__ == "__main__":182 main()183$ git log --oneline
v1.0.0
HEAD
2026-10-21Builds a .pptx on the customer's master from library slides (PR #1499)