Skip to content
Back to skills

Slides To Pdf

ASecurity

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, ...).

  • 36 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 1, 2026
developmentpythonrustgoshellbashtesting

Security analysis

A100/100

Scanned September 1, 2026

npx -y skills add glukicov/slideops --skill slides-to-pdf --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Slides To Pdf?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Slides To Pdf
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/glukicov-slides-to-pdf/badge)](https://www.skillsdirectory.com/skills/glukicov-slides-to-pdf)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: slides-to-pdf
description: 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, ...).
license: MIT
compatibility: Needs a headless Chrome or Chromium binary (Playwright cache or system install), Python 3, and pypdfium2 for PDF verification (already installed, or one pinned pip download into a throwaway venv). Works offline otherwise. macOS and Linux; Windows untested.
metadata:
  author: Gleb Lukicov
  version: 1.0.1
---

# Slides to PDF

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

```bash
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:

```bash
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

```bash
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.)

```bash
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.

```bash
"$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:

```bash
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.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…