$ cat node-template.py
P
PDF Builder
// Turns a presentation into a PDF to share or print, one page per slide. Connect ONE source: the Presentation Composer's HTML deck, printed as it shows on screen with selectable text; or the PPTX Builder's PowerPoint file, drawn by LibreOffice like its previews (fonts can differ slightly from PowerPoint, hidden slides are left out). The PDF is named after the presentation.
Output
Document
#presentation#pdf#html#pptx#powerpoint#slides#export
template.py
1"""pdf-builder — a presentation as a PDF, one page per slide.23The sibling of `node-templates/pptx-builder/`. It takes ONE source over an4edge and hands it to document-render:56 * `presentation`, the Presentation Composer's HTML deck: Chromium prints7 every slide from the presentation view at the deck's canvas size8 (`gais.render.html_to_pdf`, `POST /v1/render/html-to-pdf`). The pages look9 like the deck on screen and the text stays selectable. Any deck works, a10 built-in theme or a Layout Library one, with or without a .pptx.11 * `pptx`, the PPTX Builder's file: LibreOffice converts it, the engine that12 already draws the PPTX Builder's previews (`gais.render.pptx_to_pdf`,13 `POST /v1/render/pptx-to-pdf`), so fonts can differ from PowerPoint as14 there, and hidden slides are left out, as in PowerPoint's "Save as PDF".1516Both connected is refused rather than guessed. Render failures are library17errors and fail the node: RENDER_SERVICE_UNAVAILABLE (service down, slow or18too old for the route) or RENDER_FAILED (not a Reveal deck / not a .pptx,19nothing to print).2021The PDF is written under /data/output, named after the presentation: the22deck's <title> (the head's, as the PPTX Builder names its .pptx), or the23.pptx's own name, which the executor stages as `pptx_<name>.pptx`.2425This node holds no user token and calls nothing but `render`: the source26arrives over the edge.27"""2829from __future__ import annotations3031import html32import json33import re34import sys35import traceback36from typing import Any3738from gais import Gais3940INPUT_DIR = "/data/input"41OUTPUT_DIR = "/data/output"42DEFAULT_NAME = "presentation"4344#: The executor stages a file input as `<handle>_<name>` (edge_resolution.staging_filename).45_STAGED_PREFIX = "pptx_"46_PPTX_SUFFIX = re.compile(r"\.pptx$", re.IGNORECASE)47_TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title\s*>", re.IGNORECASE | re.DOTALL)48# The PPTX Builder's file-name rules: path and Windows-reserved characters,49# `%` (keys are URL-decoded before their traversal check), `#` and `?` (URL50# fragment and query), controls.51_UNSAFE_NAME_CHARS = re.compile(r'[\x00-\x1f\x7f<>:"/\\|?*%#]')52_MAX_NAME_CHARS = 1205354_NO_SOURCE = (55 "Required input not provided: connect the Presentation Composer's presentation "56 "or the PPTX Builder's PowerPoint output"57)58_TWO_SOURCES = (59 "Connect one source, not both: the Presentation Composer's presentation "60 "or the PPTX Builder's PowerPoint output"61)626364# ── gais adapters ───────────────────────────────────────────────────────────656667def _html_to_pdf(deck: str) -> tuple[str, str]:68 """document-render (Chromium): (PDF path, page count as reported, "" when unknown)."""69 result = Gais.render.html_to_pdf(deck)70 return result.data["pdf"], result.metadata.get("pages", "")717273def _pptx_to_pdf(pptx_path: str) -> tuple[str, str]:74 """document-render (LibreOffice): (PDF path, page count as reported, "" when unknown)."""75 result = Gais.render.pptx_to_pdf(pptx_path)76 return result.data["pdf"], result.metadata.get("pages", "")777879# ── inputs ──────────────────────────────────────────────────────────────────808182def _deck(raw: Any) -> str:83 return raw if isinstance(raw, str) and raw.strip() else ""848586def _staged_name(raw: Any) -> str:87 """Upstream file ports deliver a bare file name under /data/input."""88 name = raw.replace("\\", "/").rsplit("/", 1)[-1].strip() if isinstance(raw, str) else ""89 return "" if name in (".", "..") else name909192def _title(deck: str) -> str:93 """The deck's <title> (the head's, not one inside a slide's SVG)."""94 body_at = deck.lower().find("<body")95 head = deck if body_at < 0 else deck[:body_at]96 match = _TITLE_RE.search(head)97 return html.unescape(match.group(1)).strip() if match else ""9899100def _pptx_stem(staged_name: str) -> str:101 stem = staged_name[len(_STAGED_PREFIX):] if staged_name.startswith(_STAGED_PREFIX) else staged_name102 return _PPTX_SUFFIX.sub("", stem)103104105def _pdf_name(stem: str) -> str:106 name = _UNSAFE_NAME_CHARS.sub(" ", stem)107 name = re.sub(r"\s+", " ", name).lstrip(". ").rstrip()108 name = name[:_MAX_NAME_CHARS].rstrip(" .")109 return f"{name or DEFAULT_NAME}.pdf"110111112def _copy_file(src: str, dst: str) -> None:113 with open(src, "rb") as fin, open(dst, "wb") as fout:114 while chunk := fin.read(1024 * 1024):115 fout.write(chunk)116117118# ── run ─────────────────────────────────────────────────────────────────────119120121def process(inputs: dict[str, Any]) -> dict[str, Any]:122 deck = _deck(inputs.get("presentation"))123 staged_name = _staged_name(inputs.get("pptx"))124 if deck and staged_name:125 raise ValueError(_TWO_SOURCES)126 if deck:127 pdf_name = _pdf_name(_title(deck))128 pdf_path, pages = _html_to_pdf(deck)129 source = "HTML deck"130 elif staged_name:131 pdf_name = _pdf_name(_pptx_stem(staged_name))132 pdf_path, pages = _pptx_to_pdf(f"{INPUT_DIR}/{staged_name}")133 source = ".pptx"134 else:135 raise ValueError(_NO_SOURCE)136137 _copy_file(pdf_path, f"{OUTPUT_DIR}/{pdf_name}")138 print(f"[pdf-builder] built {pdf_name} from the {source} ({pages or '?'} pages)", file=sys.stderr)139 return {"pdf": pdf_name}140141142def main() -> None:143 try:144 envelope = json.loads(sys.stdin.read() or "{}")145 if not isinstance(envelope, dict):146 envelope = {}147 inputs = envelope.get("inputs") or {}148 json.dump(process(inputs), sys.stdout)149 except Exception as e:150 print(151 json.dumps({152 "error": str(e),153 "errorType": type(e).__name__,154 "traceback": traceback.format_exc(),155 }),156 file=sys.stderr,157 )158 sys.exit(1)159160161if __name__ == "__main__":162 main()163$ git log --oneline
v1.1.0
HEAD
2026-10-25New presentation (Html) input beside pptx: prints the Presentation Composer's HTML deck to PDF as it shows on screen (one page per slide, selectable text). Connect one input only.
v1.0.02026-10-25
First release: turns the PPTX Builder's .pptx into a PDF (LibreOffice, hidden slides left out), named after the .pptx.