$ cat node-template.py

S

Snapshot

// Turns one document into images, one per page, to show, check or pass on page by page. Connect ONE source: a PDF; a PowerPoint (.pptx), drawn by LibreOffice (fonts can differ slightly from PowerPoint, hidden slides are left out); an image (PNG, JPEG, WebP, GIF), passed through as it is; or the Presentation Composer's HTML deck. Width sets how wide each page image is (the height follows the page); Max Pages caps how many pages become images; Pages reports how many pages the source has. For a picture of a 3D model, use 3D Snapshot instead.

Process
Document
#snapshot#pdf#pptx#presentation#image#pages#render
template.py
1"""snapshot — one image per page of ONE source.23Not the 3D Snapshot (`node-templates/snapshot-3d/`, a capture of a 3D model's4preview): this node turns a document into page images. It takes ONE source5over an edge:67  * `file`, recognized by its content (magic bytes), never by its name:8      - a PDF (`%PDF-` at byte 0, behind a BOM, whitespace or NULs, or at the9        start of a line, within the first 1 KB, where pdfium still finds it)10        -> `gais.render.pdf_to_png`, rasterized inside the executor one page11        at a time;12      - a PowerPoint (a ZIP holding `ppt/presentation.xml`) ->13        `gais.render.pptx_to_pdf` (document-render, LibreOffice: hidden slides14        are left out, fonts can differ from PowerPoint) -> `pdf_to_png`;15      - an image (PNG, JPEG, WebP, GIF) -> copied through unchanged as16        `snapshot-001.<ext>`, one page, no render call;17      - anything else (an empty file, a Word / Excel / OpenDocument ZIP,18        unknown bytes) -> a ValueError naming what Snapshot takes.19  * `html`, the Presentation Composer's Reveal deck -> `gais.render.html_to_pdf`20    (document-render, Chromium) -> `pdf_to_png`. A page that is not a deck21    comes back as RENDER_FAILED{status: 422, reason: "Not a Reveal.js deck…"};22    it is re-raised with a reason saying Snapshot takes a Presentation Composer23    deck. Every other 422 (no slides, too many, canvas too large) is about a24    real deck and passes through unchanged.2526Both connected is refused rather than guessed. `width` (512-2048, default271280) sets every page image's width, the height follows the page; `maxPages`28(1-100, default 30) caps how many pages become images. The `pages` output is29the source's page count, so a cut stays visible downstream.3031Every other render failure is a library error that fails the node unchanged:32RENDER_SERVICE_UNAVAILABLE (document-render down, slow or too old) and33RENDER_FAILED (a password-protected, unreadable or empty PDF, no visible34slide).3536This node holds no user token and calls nothing but `render`: the source37arrives over the edge.38"""3940from __future__ import annotations4142import json43import re44import sys45import traceback46import zipfile47from typing import Any4849from gais import Gais50from gais.slides.errors import LibraryError5152INPUT_DIR = "/data/input"53OUTPUT_DIR = "/data/output"54PREFIX = "snapshot"5556WIDTH_DEFAULT = 128057WIDTH_MIN = 51258WIDTH_MAX = 204859MAX_PAGES_DEFAULT = 3060MAX_PAGES_MIN = 161MAX_PAGES_MAX = 1006263#: The part every PowerPoint package holds (document-render's sanitizer checks the same one).64_PPTX_PART = "ppt/presentation.xml"65#: pdfium accepts the `%PDF-` header at any offset up to 1024. Snapshot takes it66#: there behind a BOM, whitespace or NULs, or at the start of a line (printer67#: and HTTP/MIME preambles end with a line break), so a text that only mentions68#: the header mid-sentence is not sent to pdfium. The ZIP and image magics sit69#: at byte 0.70_PDF_HEADER = b"%PDF-"71_SNIFF_BYTES = 1024 + len(_PDF_HEADER)72_BEFORE_PDF_HEADER = b"\xef\xbb\xbf \t\r\n\x0b\x0c\x00"73_PDF_HEADER_ON_A_LINE = re.compile(rb"[\r\n]%PDF-")7475_IMAGE_KINDS = {"png": "PNG image", "jpg": "JPEG image", "webp": "WebP image", "gif": "GIF image"}7677_NO_SOURCE = (78    "Required input not provided: connect a PDF, a PowerPoint (.pptx) or an image to File, "79    "or the Presentation Composer's presentation to Presentation"80)81_TWO_SOURCES = (82    "Connect one source, not both: a PDF, a PowerPoint (.pptx) or an image on File, "83    "or the Presentation Composer's presentation on Presentation"84)85_UNSUPPORTED = (86    "Snapshot takes a PDF, a PowerPoint (.pptx) or an image (PNG, JPEG, WebP, GIF) on File, "87    "or a Presentation Composer deck on Presentation"88)89_GOT = {90    "empty": "an empty file",91    "zip": (92        "a ZIP package that is not a readable PowerPoint deck "93        "(Word, Excel and OpenDocument files are not supported)"94    ),95}96_NOT_A_DECK = "Snapshot takes a Presentation Composer deck; this HTML is not one"97#: How document-render's 422 names a page that is not a Reveal deck98#: (apps/document-render/renderers/html_pptx_renderer.py `_open_deck`).99_NOT_REVEAL = "Not a Reveal.js deck"100101102# ── gais adapters ───────────────────────────────────────────────────────────103104105def _pdf_to_png(pdf_path: str, width: int, max_pages: int) -> tuple[list[str], int]:106    """Rasterize in the executor: (image file names in page order, pages in the PDF)."""107    result = Gais.render.pdf_to_png(108        pdf_path, width=width, max_pages=max_pages, out_dir=OUTPUT_DIR, prefix=PREFIX,109    )110    images = [_basename(p) for p in result.data["pngs"]]111    return images, _i(result.metadata.get("pages"), len(images))112113114def _pptx_to_pdf(pptx_path: str) -> str:115    """document-render (LibreOffice), hidden slides left out: the PDF's path."""116    return Gais.render.pptx_to_pdf(pptx_path).data["pdf"]117118119def _deck_to_pdf(deck: str) -> str:120    """document-render (Chromium): the PDF's path. A non-deck page is named as such."""121    try:122        return Gais.render.html_to_pdf(deck).data["pdf"]123    except LibraryError as e:124        reason = e.params.get("reason")125        is_not_a_deck = (126            e.code == "RENDER_FAILED"127            and e.params.get("status") == 422128            and isinstance(reason, str)129            and reason.startswith(_NOT_REVEAL)130        )131        if not is_not_a_deck:132            raise133        raise LibraryError("RENDER_FAILED", status=422, reason=_NOT_A_DECK) from e134135136# ── inputs ──────────────────────────────────────────────────────────────────137138139def _i(value: Any, default: int) -> int:140    try:141        return int(float(value))142    except (TypeError, ValueError, OverflowError):143        return default144145146def _clamp(value: Any, default: int, low: int, high: int) -> int:147    return min(max(_i(value, default), low), high)148149150def _deck(raw: Any) -> str:151    return raw if isinstance(raw, str) and raw.strip() else ""152153154def _basename(path: Any) -> str:155    return str(path).replace("\\", "/").rsplit("/", 1)[-1]156157158def _staged_name(raw: Any) -> str:159    """Upstream file ports deliver a bare file name under /data/input."""160    name = _basename(raw).strip() if isinstance(raw, str) else ""161    return "" if name in (".", "..") else name162163164# ── content sniffing ────────────────────────────────────────────────────────165166167def _image_kind(head: bytes) -> str:168    if head.startswith(b"\x89PNG\r\n\x1a\n"):169        return "png"170    if head.startswith(b"\xff\xd8\xff"):171        return "jpg"172    if head[:4] == b"RIFF" and head[8:12] == b"WEBP":173        return "webp"174    if head[:6] in (b"GIF87a", b"GIF89a"):175        return "gif"176    return ""177178179def _holds_presentation(path: str) -> bool:180    try:181        with zipfile.ZipFile(path) as package:182            return _PPTX_PART in package.namelist()183    except (zipfile.BadZipFile, EOFError, ValueError):184        return False185186187def _sniff(path: str) -> str:188    """What the file is by its content: "pdf", "pptx", an image kind, "empty", "zip" or ""."""189    with open(path, "rb") as fh:190        head = fh.read(_SNIFF_BYTES)191    if not head:192        return "empty"193    if head.startswith(b"PK\x03\x04"):194        return "pptx" if _holds_presentation(path) else "zip"195    kind = _image_kind(head)196    if kind:197        return kind198    if head.lstrip(_BEFORE_PDF_HEADER).startswith(_PDF_HEADER) or _PDF_HEADER_ON_A_LINE.search(head):199        return "pdf"200    return ""201202203def _copy_file(src: str, dst: str) -> None:204    with open(src, "rb") as fin, open(dst, "wb") as fout:205        while chunk := fin.read(1024 * 1024):206            fout.write(chunk)207208209# ── run ─────────────────────────────────────────────────────────────────────210211212def process(inputs: dict[str, Any]) -> dict[str, Any]:213    deck = _deck(inputs.get("html"))214    staged_name = _staged_name(inputs.get("file"))215    if deck and staged_name:216        raise ValueError(_TWO_SOURCES)217    if not deck and not staged_name:218        raise ValueError(_NO_SOURCE)219    width = _clamp(inputs.get("width"), WIDTH_DEFAULT, WIDTH_MIN, WIDTH_MAX)220    max_pages = _clamp(inputs.get("maxPages"), MAX_PAGES_DEFAULT, MAX_PAGES_MIN, MAX_PAGES_MAX)221    how = f"width {width} px"222223    if deck:224        images, pages = _pdf_to_png(_deck_to_pdf(deck), width, max_pages)225        source = "Presentation Composer deck"226    else:227        path = f"{INPUT_DIR}/{staged_name}"228        kind = _sniff(path)229        if kind == "pdf":230            images, pages = _pdf_to_png(path, width, max_pages)231            source = "PDF"232        elif kind == "pptx":233            images, pages = _pdf_to_png(_pptx_to_pdf(path), width, max_pages)234            source = ".pptx"235        elif kind in _IMAGE_KINDS:236            image = f"{PREFIX}-001.{kind}"237            _copy_file(path, f"{OUTPUT_DIR}/{image}")238            images, pages = [image], 1239            source = _IMAGE_KINDS[kind]240            how = "passed through unchanged"241        else:242            got = _GOT.get(kind, "a file that is none of these")243            # No file name in the message: an upstream node chooses it, and the canvas244            # reads a `[LIBRARY] {…}` marker anywhere in an error as its strip.245            raise ValueError(f"{_UNSUPPORTED}. Got {got}.")246247    if pages > len(images):248        print(f"[snapshot] kept {len(images)} of {pages} pages (Max Pages)", file=sys.stderr)249    print(250        f"[snapshot] {len(images)} image(s) from the {source} ({pages} page(s), {how})",251        file=sys.stderr,252    )253    return {"images": images, "pages": pages}254255256def main() -> None:257    try:258        envelope = json.loads(sys.stdin.read() or "{}")259        if not isinstance(envelope, dict):260            envelope = {}261        inputs = envelope.get("inputs") or {}262        json.dump(process(inputs), sys.stdout)263    except Exception as e:264        print(265            json.dumps({266                "error": str(e),267                "errorType": type(e).__name__,268                "traceback": traceback.format_exc(),269            }),270            file=sys.stderr,271        )272        sys.exit(1)273274275if __name__ == "__main__":276    main()277

$ git log --oneline

v1.0.0
HEAD
2026-10-29

First release: turns one document into page images. Connect one source: a PDF, a PowerPoint (.pptx), an image, or the Presentation Composer's deck; get images (one PNG per page) and pages.