$ cat node-template.py
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.
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
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.