$npx -y skills add bluzir/claude-code-design --skill export-pdfExport an HTML artifact to PDF via headless Chromium (puppeteer Page.pdf). For multi-slide decks one page per <section>.
| 1 | # Export PDF |
| 2 | |
| 3 | Produce a PDF from an HTML artifact. Uses puppeteer's `Page.pdf()` which is lossless vector when the source is HTML/CSS/SVG. |
| 4 | |
| 5 | **Why puppeteer instead of Chrome DevTools MCP:** the MCP does not expose `Page.printToPDF` directly (only `take_screenshot`, `list_console_messages`, `evaluate_script`, etc.). Using puppeteer via a Node script is deterministic and matches `/export-pptx`. |
| 6 | |
| 7 | ## Preflight |
| 8 | |
| 9 | 1. `Bash(which node)` — required |
| 10 | 2. `Bash(test -d node_modules/puppeteer && echo yes || echo no)` — if no, tell user `/doctor` or run `npm i -D puppeteer` |
| 11 | |
| 12 | ## Steps |
| 13 | |
| 14 | 1. Resolve paths: |
| 15 | - `$0` = input HTML (required) |
| 16 | - `$1` = output (default: input with `.pdf` extension) |
| 17 | - `Bash(mkdir -p $(dirname <output>))` if needed |
| 18 | |
| 19 | 2. Run export script: |
| 20 | ``` |
| 21 | Bash(node scripts/export-pdf.mjs "<input>" "<output>") |
| 22 | ``` |
| 23 | (The script exists at `scripts/export-pdf.mjs`; if missing, write it per the template below.) |
| 24 | |
| 25 | 3. Report size + path. |
| 26 | |
| 27 | ## Script template (scripts/export-pdf.mjs) |
| 28 | |
| 29 | ```js |
| 30 | #!/usr/bin/env node |
| 31 | // Usage: node export-pdf.mjs <input.html> [output.pdf] |
| 32 | import puppeteer from 'puppeteer'; |
| 33 | import { pathToFileURL } from 'node:url'; |
| 34 | import path from 'node:path'; |
| 35 | import fs from 'node:fs/promises'; |
| 36 | |
| 37 | const [, , input, output = input.replace(/\.html?$/i, '.pdf')] = process.argv; |
| 38 | if (!input) { console.error('Usage: export-pdf.mjs <input.html> [output.pdf]'); process.exit(1); } |
| 39 | |
| 40 | const inAbs = path.resolve(process.cwd(), input); |
| 41 | const outAbs = path.resolve(process.cwd(), output); |
| 42 | |
| 43 | const browser = await puppeteer.launch({ args: ['--no-sandbox'] }); |
| 44 | const page = await browser.newPage(); |
| 45 | await page.goto(pathToFileURL(inAbs).toString(), { waitUntil: 'networkidle0' }); |
| 46 | |
| 47 | // If it's a deck, set noscale so deck-stage renders at natural dims |
| 48 | const deckDims = await page.evaluate(() => { |
| 49 | const d = document.querySelector('deck-stage'); |
| 50 | if (!d) return null; |
| 51 | d.setAttribute('noscale', ''); |
| 52 | return [parseInt(d.getAttribute('width') || '1920', 10), parseInt(d.getAttribute('height') || '1080', 10)]; |
| 53 | }); |
| 54 | |
| 55 | await page.emulateMediaType('print'); |
| 56 | const pdfOpts = { |
| 57 | path: outAbs, |
| 58 | printBackground: true, |
| 59 | }; |
| 60 | if (deckDims) { |
| 61 | // Deck: one section per page — @media print in deck_stage.js handles page-break |
| 62 | pdfOpts.width = `${deckDims[0]}px`; |
| 63 | pdfOpts.height = `${deckDims[1]}px`; |
| 64 | pdfOpts.pageRanges = ''; |
| 65 | } else { |
| 66 | pdfOpts.format = 'A4'; |
| 67 | } |
| 68 | |
| 69 | await page.pdf(pdfOpts); |
| 70 | await browser.close(); |
| 71 | |
| 72 | const stat = await fs.stat(outAbs); |
| 73 | console.log(`wrote ${outAbs} (${(stat.size / 1024).toFixed(1)} KB)`); |
| 74 | ``` |
| 75 | |
| 76 | ## Notes |
| 77 | |
| 78 | - For decks: deck_stage.js must have `@media print { @page { size: ... } section { page-break-after: always } }` in the global `<style>` it injects — already present. |
| 79 | - For general pages: defaults to A4. To override, user can pass inline CSS `@page { size: letter }` or similar. |
| 80 | - `networkidle0` waits for all network activity to stop — if artifact uses lazy images, bump timeout to 15000ms. |