$ 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-21

Builds a .pptx on the customer's master from library slides (PR #1499)