Claude Skill

slides-to-pdf

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 U

LLM Mart · 0 points · 8 views 1 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download glukicov-slideops-skills_slides-to-pdf-6020266.zip · 4 KB
Part of glukicov/slideops — 2 skills

Install

skills CLI npx skills add https://github.com/glukicov/slideops/tree/main/skills/slides-to-pdf
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install glukicov-slideops@llmmart
Git git clone https://github.com/glukicov/slideops.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole glukicov/slideops collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

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

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.

Files (slideops)
  • SKILL.md 8.7 KB
    ---
    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.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related