$ cat node-template.py
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.
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
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.
First release: turns the Composer's deck into a PowerPoint master (.pptx), one slide layout per slide, through document-render html-to-master.