$ cat node-template.py

M

Master from Slides

// Turns a designed deck into a PowerPoint master for a brand: every slide of the Presentation Composer's deck becomes one slide layout, its look as the layout background and its title, text, lists, picture, table and slide number as placeholders that keep the slide's fonts, sizes and colours; a preview deck, one slide per layout with its sample text, shows the master as PowerPoint draws it. Design the slides with Slide Generators in layout design mode (a layout name on each), then save the master to Drive and select it in a Brand Master to publish it as a layout library.

Process
Document
#presentation#pptx#powerpoint#master#template#brand#layout#library
template.py
1"""master-from-slides — turn a designed deck into a PowerPoint master.23A brand starting from nothing designs its layouts as slides (Slide Generators4in layout design mode, joined by the Presentation Composer), and this node5turns that deck into the master a Brand Master publishes as a layout library:6`gais.render.html_to_master` (document-render) lays the deck out in Chromium7and writes one slide layout per slide, the slide's look as the layout8background and its content elements as typed placeholders. The deck's brand9kit (the Composer's `pptx-theme` meta tag) sets the master's theme.1011Inputs come over edges or are typed: the Composer's `presentation` HTML and an12optional `name`. This node holds no user token and makes no Drive or library13call; Save File stores the master and the Brand Master reads it.1415The master is named after `name`, else the deck's <title>; the same name goes16to document-render as the theme's name. Errors from document-render are17library errors (`RENDER_FAILED` with the route's reason, e.g. "not a Reveal.js18deck", or `RENDER_SERVICE_UNAVAILABLE`) and are passed through unchanged.1920Next to the master the node writes `<name>-preview.pptx`, a deck to look at21the master with (e.g. with Snapshot). The master has layouts and no slides,22and a layout's sample text is its placeholders' prompt, which is not drawn: a23render of the master shows nothing. The preview has one slide per layout, in24the master's order, each text placeholder holding the layout's sample text, so25a render shows the master as PowerPoint would (text that no longer fits,26another typeface, a moved slot). Picture and table areas are empty (the27preview holds no photo or table: those placeholders are removed, so the28layout background shows, as in the HTML) and no slide number is shown. The29master is the product and the preview30only a check aid: if the preview cannot be built, the node warns on stderr and31returns without `preview`.32"""3334from __future__ import annotations3536import html37import io38import json39import re40import sys41import traceback42from typing import Any4344from gais import Gais45from pptx import Presentation46from pptx.enum.shapes import PP_PLACEHOLDER4748OUTPUT_DIR = "/data/output"49DEFAULT_NAME = "brand-master"5051# The placeholders a preview slide keeps and fills with text. Every other one52# add_slide clones (picture, table, chart, media...) is removed: empty, it is53# no part of the master's look, yet LibreOffice (Snapshot's pptx-to-pdf) draws54# an empty table placeholder as a filled box. Slide number, date and footer55# are not cloned at all (python-pptx's add_slide, like PowerPoint's New Slide).56_TEXT_PLACEHOLDERS = frozenset({57    PP_PLACEHOLDER.TITLE,58    PP_PLACEHOLDER.CENTER_TITLE,59    PP_PLACEHOLDER.BODY,60    PP_PLACEHOLDER.OBJECT,61    PP_PLACEHOLDER.SUBTITLE,62})6364_TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title\s*>", re.IGNORECASE | re.DOTALL)65# Characters a file name may not carry through the workspace file routes:66# path and Windows-reserved characters, `%` (keys are URL-decoded before67# their traversal check), `#` and `?` (URL fragment and query), controls.68_UNSAFE_NAME_CHARS = re.compile(r'[\x00-\x1f\x7f<>:"/\\|?*%#]')69_MAX_NAME_CHARS = 12070# A file name is at most 255 bytes; 120 CJK characters are 360 in UTF-8.71_MAX_NAME_BYTES = 200727374# ── gais adapter ────────────────────────────────────────────────────────────757677def _render_master(presentation: str, name: str) -> tuple[str, int | None]:78    """document-render: (path of the master, layout count when announced)."""79    result = Gais.render.html_to_master(presentation, title=name)80    layouts = result.metadata.get("layouts")81    return result.data["master"], int(layouts) if layouts and layouts.isdigit() else None828384# ── inputs ──────────────────────────────────────────────────────────────────858687def _presentation(raw: Any) -> str:88    if not isinstance(raw, str) or not raw.strip():89        raise ValueError(90            "Required input 'presentation' not provided: connect the Presentation Composer's output"91        )92    return raw939495def _title(presentation: str) -> str:96    """The Composer's <title> (the head's, not one inside a slide's SVG)."""97    body_at = presentation.lower().find("<body")98    head = presentation if body_at < 0 else presentation[:body_at]99    match = _TITLE_RE.search(head)100    return html.unescape(match.group(1)).strip() if match else ""101102103def _master_name(raw: Any, presentation: str) -> str:104    name = raw.strip() if isinstance(raw, str) else ""105    name = _UNSAFE_NAME_CHARS.sub(" ", name or _title(presentation))106    name = re.sub(r"\s+", " ", name).lstrip(". ").rstrip()107    name = name[:_MAX_NAME_CHARS].encode("utf-8", "ignore")[:_MAX_NAME_BYTES].decode("utf-8", "ignore")108    return name.rstrip(" .") or DEFAULT_NAME109110111def _copy_file(src: str, dst: str) -> None:112    with open(src, "rb") as fin, open(dst, "wb") as fout:113        while chunk := fin.read(1024 * 1024):114            fout.write(chunk)115116117# ── preview ─────────────────────────────────────────────────────────────────118119120def _copy_prompt(source: Any, target: Any) -> None:121    """The layout placeholder's prompt onto the slide's: one paragraph each122    (a bullet list is one paragraph per item), line breaks kept (python-pptx123    reads and writes them as "\\v"), the text only: the slide inherits the124    layout's list style, as a slide made in PowerPoint does."""125    for i, paragraph in enumerate(source.paragraphs):126        copy = target.paragraphs[0] if i == 0 else target.add_paragraph()127        copy.text = paragraph.text128        if paragraph.level:129            copy.level = paragraph.level130131132def _build_preview(master_file: str, preview_file: str) -> int:133    """One slide per layout of the master, its text placeholders holding the134    layout's sample text and no other placeholder (the layout background shows135    there, as in the HTML); returns the slide count. The deck is built in136    memory and written once: every file in /data/output is uploaded, so a137    failure must not leave half a file there."""138    prs = Presentation(master_file)139    for layout in list(prs.slide_layouts):140        slide = prs.slides.add_slide(layout)  # clones the layout's placeholders141        prompts = {ph.placeholder_format.idx: ph for ph in layout.placeholders}142        for placeholder in list(slide.placeholders):143            if placeholder.placeholder_format.type not in _TEXT_PLACEHOLDERS:144                placeholder.element.getparent().remove(placeholder.element)145                continue146            source = prompts.get(placeholder.placeholder_format.idx)147            # txBody, not has_text_frame (always True on a shape): reading148            # text_frame would add an empty text body to the layout.149            if source is not None and source.element.txBody is not None:150                _copy_prompt(source.text_frame, placeholder.text_frame)151    buffer = io.BytesIO()152    prs.save(buffer)153    with open(preview_file, "wb") as fout:154        fout.write(buffer.getvalue())155    return len(prs.slides)156157158# ── run ─────────────────────────────────────────────────────────────────────159160161def process(inputs: dict[str, Any]) -> dict[str, Any]:162    presentation = _presentation(inputs.get("presentation"))163    name = _master_name(inputs.get("name"), presentation)164    master_path, layouts = _render_master(presentation, name)165    file_name = f"{name}.pptx"166    _copy_file(master_path, f"{OUTPUT_DIR}/{file_name}")167    print(f"[master-from-slides] wrote {file_name} ({layouts} layouts)", file=sys.stderr)168    outputs: dict[str, Any] = {"master": file_name, "layouts": layouts if layouts is not None else 0}169170    # The master is the product; the preview only helps check it, so a171    # preview that cannot be built costs the preview, not the run.172    preview_name = f"{name}-preview.pptx"173    try:174        slides = _build_preview(f"{OUTPUT_DIR}/{file_name}", f"{OUTPUT_DIR}/{preview_name}")175    except Exception as e:176        reason = " ".join(str(e).split())177        print(f"[master-from-slides] warning: no preview ({type(e).__name__}: {reason})", file=sys.stderr)178        return outputs179    print(f"[master-from-slides] wrote {preview_name} ({slides} slides)", file=sys.stderr)180    outputs["preview"] = preview_name181    return outputs182183184def main() -> None:185    try:186        envelope = json.loads(sys.stdin.read() or "{}")187        if not isinstance(envelope, dict):188            envelope = {}189        inputs = envelope.get("inputs") or {}190        json.dump(process(inputs), sys.stdout)191    except Exception as e:192        print(193            json.dumps({194                "error": str(e),195                "errorType": type(e).__name__,196                "traceback": traceback.format_exc(),197            }),198            file=sys.stderr,199        )200        sys.exit(1)201202203if __name__ == "__main__":204    main()205

$ git log --oneline

v1.1.0
HEAD
2026-10-29

1.1.0: a preview output, <name>-preview.pptx: one slide per layout of the master with its sample text in the text placeholders (picture, table and slide-number placeholders left off), so the master can be looked at as PowerPoint draws it (Snapshot). The master is unchanged; if the preview cannot be built, the node warns and returns the master alone.

v1.0.02026-10-26

First release: turns the Composer's deck into a PowerPoint master (.pptx), one slide layout per slide, through document-render html-to-master.