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

New 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.