Slides to PDF

SkillFiles & storage

Use when the user asks to convert, export, or save an HTML slide deck as a PDF ("save the slides as PDF", "export the deck to PDF", "make a PDF of the presentation"). Works on decks built by the SlideOps (slideops) skill and on any single-file HTML deck that shows one slide per URL hash (#1, #2, ...).

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Slides to PDF skill

What this skill tells your AI

The instructions your AI receives, as published by glukicov/slideops in skills/slides-to-pdf/SKILL.md and read by ahel’s review.

Converts a JS-driven, one-slide-per-screen HTML deck into a paginated PDF: one page per slide, matching the browser rendering, with a centred page number in the footer. There is no native "print to PDF" for such decks (printing captures only the visible slide), so the pipeline is: screenshot every slide at 2x with headless Chrome, wrap the screenshots in a print-paginated page, number them, print that to PDF, then verify the PDF by rendering it back to images.

Inputs to establish up front (ask only if not obvious):

  • Deck path, the literal that marks one slide (default <section class="slide"), and the deck's canvas size (default 1280x720). Read the deck's own CSS to confirm both before you start; a wrong size silently letterboxes or crops every page.
  • Output path: default next to the HTML, same basename, .pdf.
  • Chrome-only UI to hide: for SlideOps decks this is .hud,.progress,.hint; for other decks, identify the fixed nav/progress elements a printed page shouldn't show.

1. Find Chrome and make a staging directory

find_chrome() {
  local base c
  for base in "$HOME/Library/Caches/ms-playwright" "$HOME/.cache/ms-playwright"; do
    [ -d "$base" ] || continue
    c=$(find "$base" -maxdepth 6 -type f \
          \( -name chrome -o -name Chromium -o -name "Google Chrome for Testing" \) \
          2>/dev/null | grep -v headless_shell | sort -V | tail -1)
    [ -n "$c" ] && { printf '%s\n' "$c"; return; }
  done
  for c in "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
           "$(command -v google-chrome || true)" "$(command -v chromium || true)"; do
    [ -x "$c" ] && { printf '%s\n' "$c"; return; }
  done
}
CHROME=$(find_chrome)
[ -x "$CHROME" ] || { npx --yes playwright install chromium; CHROME=$(find_chrome); }
"$CHROME" --version   # sanity-check; quote "$CHROME" everywhere (macOS path has spaces)

DECK="/absolute/path/to/deck.html"
STAGE=$(mktemp -d -t slidespdf)   # or a per-deck subdir of your session scratchpad

# Per-deck settings. The defaults match SlideOps decks; change all three for a foreign one.
SLIDE_MATCH='<section class="slide'   # the literal that starts one slide element
W=1280; H=720                         # the deck's canvas size in CSS pixels
CHROME_SANDBOX_ARGS=()                # see "Sandbox" below before adding --no-sandbox

N=$(grep -c "$SLIDE_MATCH" "$DECK")
[ "$N" -gt 0 ] || { echo "No slides matched $SLIDE_MATCH; check the selector"; exit 1; }
echo "$N slides at ${W}x${H}"

Never reuse a fixed staging path: parallel exports collide. Everything below writes into $STAGE; the directory is deleted at the end.

Sandbox. These commands deliberately do not pass --no-sandbox. You may be rendering a deck you did not author, and the sandbox is what contains a malicious payload. Add it to CHROME_SANDBOX_ARGS only when Chrome cannot start as root in a container that cannot grant user namespaces, and tell the user you did; running as a non-root user is the better fix.

2. Build an export copy

Two reasons not to screenshot the deck in place: relative image paths (../img/...) break the moment screenshots need the HTML somewhere else, and the printed pages must not show interactive-only chrome (nav buttons, progress bar, hints). Decks whose images are inlined as data: URIs skip the path problem but still need the chrome hidden, so build the export copy either way:

IMG_DIR="/absolute/path/to/the/deck's/image/folder"   # any path is fine for an image-free deck
CHROME_HIDE='.hud,.progress,.hint'                    # SlideOps chrome; change for a foreign deck
python3 - "$DECK" "$STAGE" "$IMG_DIR" "$CHROME_HIDE" <<'EOF'
import sys
from pathlib import Path

deck, stage, img_dir, hide = Path(sys.argv[1]), Path(sys.argv[2]), sys.argv[3], sys.argv[4]
content = deck.read_text()
content = content.replace('src="../img/', f'src="file://{img_dir}/')
content = content.replace('</head>',
    f'<style>{hide}{{display:none !important;}}</style></head>', 1)
(stage / "export.html").write_text(content)
EOF

Adapt the src="../img/ prefix to the deck's actual relative-path shape, and the hidden selectors to the deck's actual chrome.

3. Screenshot every slide at 2x, wrap, print

for i in $(seq 1 $N); do
  "$CHROME" --headless=new --disable-gpu --hide-scrollbars "${CHROME_SANDBOX_ARGS[@]}" \
    --window-size=$W,$H --force-device-scale-factor=2 \
    --screenshot="$STAGE/slide-$(printf "%02d" $i).png" \
    "file://$STAGE/export.html#${i}"
done

($W/$H drive both the screenshots and the @page rule below, so a foreign deck only needs those two numbers changed.)

python3 - "$STAGE" "$N" "$W" "$H" <<'EOF'
import sys
from pathlib import Path

stage, n, w, h = Path(sys.argv[1]), int(sys.argv[2]), int(sys.argv[3]), int(sys.argv[4])
pages = "\n".join(
    f'<div class="page"><img src="slide-{i:02d}.png"><div class="folio">'
    f'<span>{i} / {n}</span></div></div>' for i in range(1, n + 1))
(stage / "print.html").write_text(f'''<style>
  @page {{ size: {w}px {h}px; margin: 0; }}
  * {{ margin: 0; padding: 0; box-sizing: border-box; }}
  .page {{ width: {w}px; height: {h}px; overflow: hidden; page-break-after: always;
           position: relative; }}
  .page:last-child {{ page-break-after: auto; }}
  .page img {{ width: {w}px; height: {h}px; display: block; }}
  .folio {{ position: absolute; left: 0; right: 0; bottom: 18px; text-align: center;
            font: 13px/1 -apple-system, "Segoe UI", Roboto, sans-serif;
            font-variant-numeric: tabular-nums; }}
  .folio span {{ background: rgba(0, 0, 0, 0.38); color: #fff;
                 padding: 6px 13px; border-radius: 999px; }}
</style>
{pages}''')
EOF

The .folio div is the page number. It has to be drawn here rather than left to CSS paged-media margin boxes, because the pages are full-bleed screenshots with a zero @page margin: there is no margin box to print into. Numbering the wrapper also means the count is exact by construction instead of inferred from how Chrome paginated.

It sits at bottom: 18px, where the deck's own HUD counter was before step 2 hid it, so it lands on space the slide already keeps clear. The translucent pill is what keeps it legible on a light and a dark deck alike; white text alone disappears on one of them. Keep --no-pdf-header-footer below, or Chrome adds a second, right-aligned number of its own.

"$CHROME" --headless=new --disable-gpu "${CHROME_SANDBOX_ARGS[@]}" \
  --print-to-pdf="$STAGE/output.pdf" --no-pdf-header-footer \
  "file://$STAGE/print.html"

4. Verify: headless Chrome cannot rasterize a local PDF

chrome --headless=new --screenshot against a file://…pdf URL produces a blank/dark image (no PDF viewer in headless mode); don't trust it as a check. Render the PDF back to images with a real PDF library in a throwaway venv:

python3 -m venv "$STAGE/venv"
"$STAGE/venv/bin/pip" install --quiet pypdfium2 Pillow
"$PDFPY" - "$STAGE" <<'EOF'
import sys
import pypdfium2 as pdfium
stage = sys.argv[1]
pdf = pdfium.PdfDocument(f"{stage}/output.pdf")
n = len(pdf)
print("page count:", n)                 # must equal the slide count
for i in range(n):
    pdf[i].render(scale=1.5).to_pil().save(f"{stage}/check-{i+1:02d}.png")
    footer = f"{i+1} / {n}"             # the folio is real text, so it extracts
    assert footer in pdf[i].get_textpage().get_text_range(), f"page {i+1} has no footer"
print("every page is numbered")
EOF

Then view a representative sample with your image tool: the first page, the last page, and every page that embeds an image. A PDF can have the right page count while every image page is silently blank (the relative-path gotcha step 2 exists to prevent); only rendering pages back to images catches that.

5. Ship and clean up

Copy the verified output.pdf to the output path (default: next to the HTML, .pdf extension), then delete the entire $STAGE directory. Nothing but the PDF should remain.

Companion skill

Decks in this format are produced by the SlideOps (slideops) skill, whose Step 5 defers to this skill for PDF export. This skill is self-contained: it does not require slideops to be installed.

Signals

GitHub stars
49
Forks
3
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
slides-to-pdf
Source
github.com/glukicov/slideops