export-pdf.sh 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  1. #!/usr/bin/env bash
  2. # export-pdf.sh — Export an HTML presentation to PDF
  3. #
  4. # Usage:
  5. # bash scripts/export-pdf.sh <path-to-html> [output.pdf]
  6. #
  7. # Examples:
  8. # bash scripts/export-pdf.sh ./my-deck/index.html
  9. # bash scripts/export-pdf.sh ./presentation.html ./presentation.pdf
  10. #
  11. # What this does:
  12. # 1. Starts a local server to serve the HTML (fonts and assets need HTTP)
  13. # 2. Uses Playwright to screenshot each slide at 1920x1080
  14. # 3. Combines all screenshots into a single PDF
  15. # 4. Cleans up the server and temp files
  16. #
  17. # The PDF preserves colors, fonts, and layout — but not animations.
  18. # Perfect for email attachments, printing, or embedding in documents.
  19. set -euo pipefail
  20. # ─── Colors ────────────────────────────────────────────────
  21. RED='\033[0;31m'
  22. GREEN='\033[0;32m'
  23. CYAN='\033[0;36m'
  24. YELLOW='\033[1;33m'
  25. BOLD='\033[1m'
  26. NC='\033[0m'
  27. info() { echo -e "${CYAN}ℹ${NC} $*"; }
  28. ok() { echo -e "${GREEN}✓${NC} $*"; }
  29. warn() { echo -e "${YELLOW}⚠${NC} $*"; }
  30. err() { echo -e "${RED}✗${NC} $*" >&2; }
  31. # ─── Parse flags ──────────────────────────────────────────
  32. # Default resolution: 1920x1080 (full HD, ~1-2MB per slide)
  33. # Compact resolution: 1280x720 (HD, ~50-70% smaller files)
  34. VIEWPORT_W=1920
  35. VIEWPORT_H=1080
  36. COMPACT=false
  37. POSITIONAL=()
  38. for arg in "$@"; do
  39. case $arg in
  40. --compact)
  41. COMPACT=true
  42. VIEWPORT_W=1280
  43. VIEWPORT_H=720
  44. ;;
  45. *)
  46. POSITIONAL+=("$arg")
  47. ;;
  48. esac
  49. done
  50. set -- "${POSITIONAL[@]}"
  51. # ─── Input validation ─────────────────────────────────────
  52. if [[ $# -lt 1 ]]; then
  53. err "Usage: bash scripts/export-pdf.sh <path-to-html> [output.pdf] [--compact]"
  54. err ""
  55. err "Examples:"
  56. err " bash scripts/export-pdf.sh ./my-deck/index.html"
  57. err " bash scripts/export-pdf.sh ./presentation.html ./slides.pdf"
  58. err " bash scripts/export-pdf.sh ./presentation.html --compact # smaller file size"
  59. exit 1
  60. fi
  61. INPUT_HTML="$1"
  62. if [[ ! -f "$INPUT_HTML" ]]; then
  63. err "File not found: $INPUT_HTML"
  64. exit 1
  65. fi
  66. # Resolve to absolute path
  67. INPUT_HTML=$(cd "$(dirname "$INPUT_HTML")" && pwd)/$(basename "$INPUT_HTML")
  68. # Output PDF path: use second argument or derive from input name
  69. if [[ $# -ge 2 ]]; then
  70. OUTPUT_PDF="$2"
  71. else
  72. OUTPUT_PDF="$(dirname "$INPUT_HTML")/$(basename "$INPUT_HTML" .html).pdf"
  73. fi
  74. # Resolve output to absolute path
  75. OUTPUT_DIR=$(dirname "$OUTPUT_PDF")
  76. mkdir -p "$OUTPUT_DIR"
  77. OUTPUT_PDF="$OUTPUT_DIR/$(basename "$OUTPUT_PDF")"
  78. echo ""
  79. echo -e "${BOLD}╔══════════════════════════════════════╗${NC}"
  80. echo -e "${BOLD}║ Export Slides to PDF ║${NC}"
  81. echo -e "${BOLD}╚══════════════════════════════════════╝${NC}"
  82. echo ""
  83. # ─── Step 1: Check dependencies ───────────────────────────
  84. info "Checking dependencies..."
  85. if ! command -v npx &>/dev/null; then
  86. err "Node.js is required but not installed."
  87. err ""
  88. err "Install Node.js:"
  89. err " macOS: brew install node"
  90. err " or visit https://nodejs.org and download the installer"
  91. exit 1
  92. fi
  93. ok "Node.js found"
  94. # ─── Step 2: Create the export script ─────────────────────
  95. # We use a temporary Node.js script with Playwright to:
  96. # 1. Start a local server (so fonts load correctly)
  97. # 2. Navigate to each slide
  98. # 3. Screenshot each slide at 1920x1080 (16:9 landscape)
  99. # 4. Combine into a single PDF
  100. TEMP_DIR=$(mktemp -d)
  101. TEMP_SCRIPT="$TEMP_DIR/export-slides.mjs"
  102. # Figure out which directory to serve (the folder containing the HTML)
  103. SERVE_DIR=$(dirname "$INPUT_HTML")
  104. HTML_FILENAME=$(basename "$INPUT_HTML")
  105. cat > "$TEMP_SCRIPT" << 'EXPORT_SCRIPT'
  106. // export-slides.mjs — Playwright script to export HTML slides to PDF
  107. //
  108. // How it works:
  109. // 1. Starts a local HTTP server (needed for fonts/assets to load)
  110. // 2. Opens the presentation in a headless browser at 1920x1080
  111. // 3. Counts the total number of slides
  112. // 4. Screenshots each slide one by one
  113. // 5. Generates a PDF with all slides as landscape pages
  114. import { chromium } from 'playwright';
  115. import { createServer } from 'http';
  116. import { readFileSync, existsSync, mkdirSync, unlinkSync, writeFileSync } from 'fs';
  117. import { join, extname, resolve } from 'path';
  118. import { execSync } from 'child_process';
  119. const SERVE_DIR = process.argv[2];
  120. const HTML_FILE = process.argv[3];
  121. const OUTPUT_PDF = process.argv[4];
  122. const SCREENSHOT_DIR = process.argv[5];
  123. const VP_WIDTH = parseInt(process.argv[6]) || 1920;
  124. const VP_HEIGHT = parseInt(process.argv[7]) || 1080;
  125. // ─── Simple static file server ────────────────────────────
  126. // (We need HTTP so that Google Fonts and relative assets load correctly)
  127. const MIME_TYPES = {
  128. '.html': 'text/html',
  129. '.css': 'text/css',
  130. '.js': 'application/javascript',
  131. '.json': 'application/json',
  132. '.png': 'image/png',
  133. '.jpg': 'image/jpeg',
  134. '.jpeg': 'image/jpeg',
  135. '.gif': 'image/gif',
  136. '.svg': 'image/svg+xml',
  137. '.webp': 'image/webp',
  138. '.woff': 'font/woff',
  139. '.woff2': 'font/woff2',
  140. '.ttf': 'font/ttf',
  141. '.eot': 'application/vnd.ms-fontobject',
  142. };
  143. const server = createServer((req, res) => {
  144. // Decode URL-encoded characters (e.g., %20 → space) so filenames with spaces resolve correctly
  145. const decodedUrl = decodeURIComponent(req.url);
  146. let filePath = join(SERVE_DIR, decodedUrl === '/' ? HTML_FILE : decodedUrl);
  147. try {
  148. const content = readFileSync(filePath);
  149. const ext = extname(filePath).toLowerCase();
  150. res.writeHead(200, { 'Content-Type': MIME_TYPES[ext] || 'application/octet-stream' });
  151. res.end(content);
  152. } catch {
  153. res.writeHead(404);
  154. res.end('Not found');
  155. }
  156. });
  157. // Find a free port
  158. const port = await new Promise((resolve) => {
  159. server.listen(0, () => resolve(server.address().port));
  160. });
  161. console.log(` Local server on port ${port}`);
  162. // ─── Screenshot each slide ────────────────────────────────
  163. const browser = await chromium.launch();
  164. const page = await browser.newPage({
  165. viewport: { width: VP_WIDTH, height: VP_HEIGHT },
  166. });
  167. // Load the presentation
  168. await page.goto(`http://localhost:${port}/`, { waitUntil: 'networkidle' });
  169. // Wait for fonts to load
  170. await page.evaluate(() => document.fonts.ready);
  171. // Extra wait for animations to settle on the first slide
  172. await page.waitForTimeout(1500);
  173. // Count slides
  174. const slideCount = await page.evaluate(() => {
  175. return document.querySelectorAll('.slide').length;
  176. });
  177. console.log(` Found ${slideCount} slides`);
  178. if (slideCount === 0) {
  179. console.error(' ERROR: No .slide elements found in the presentation.');
  180. console.error(' Make sure your HTML uses <div class="slide"> or <section class="slide">.');
  181. await browser.close();
  182. server.close();
  183. process.exit(1);
  184. }
  185. // Screenshot each slide
  186. mkdirSync(SCREENSHOT_DIR, { recursive: true });
  187. const screenshotPaths = [];
  188. for (let i = 0; i < slideCount; i++) {
  189. // Navigate to slide by simulating the presentation's navigation
  190. // Most frontend-slides presentations use a currentSlide index and show/hide
  191. await page.evaluate((index) => {
  192. const slides = document.querySelectorAll('.slide');
  193. // Try multiple navigation strategies used by frontend-slides:
  194. // Strategy 1: Direct slide manipulation (most common in generated decks)
  195. slides.forEach((slide, idx) => {
  196. if (idx === index) {
  197. slide.style.display = '';
  198. slide.style.opacity = '1';
  199. slide.style.visibility = 'visible';
  200. slide.style.position = 'relative';
  201. slide.style.transform = 'none';
  202. slide.classList.add('active');
  203. } else {
  204. slide.style.display = 'none';
  205. slide.classList.remove('active');
  206. }
  207. });
  208. // Strategy 2: If there's a SlidePresentation class instance, use it
  209. if (window.presentation && typeof window.presentation.goToSlide === 'function') {
  210. window.presentation.goToSlide(index);
  211. }
  212. // Strategy 3: Scroll-based (some decks use scroll snapping)
  213. slides[index]?.scrollIntoView({ behavior: 'instant' });
  214. }, i);
  215. // Wait for any slide transition animations to finish
  216. await page.waitForTimeout(300);
  217. // Wait for intersection observer animations to trigger
  218. await page.waitForTimeout(200);
  219. // Force all .reveal elements on the current slide to be visible
  220. // (animations normally trigger on scroll/intersection, but we need them visible now)
  221. await page.evaluate((index) => {
  222. const slides = document.querySelectorAll('.slide');
  223. const currentSlide = slides[index];
  224. if (currentSlide) {
  225. currentSlide.querySelectorAll('.reveal').forEach(el => {
  226. el.style.opacity = '1';
  227. el.style.transform = 'none';
  228. el.style.visibility = 'visible';
  229. });
  230. }
  231. }, i);
  232. await page.waitForTimeout(100);
  233. const screenshotPath = join(SCREENSHOT_DIR, `slide-${String(i + 1).padStart(3, '0')}.png`);
  234. await page.screenshot({ path: screenshotPath, fullPage: false });
  235. screenshotPaths.push(screenshotPath);
  236. console.log(` Captured slide ${i + 1}/${slideCount}`);
  237. }
  238. await browser.close();
  239. server.close();
  240. // ─── Combine screenshots into PDF ─────────────────────────
  241. // Use a second Playwright page to generate a PDF from the screenshots
  242. console.log(' Assembling PDF...');
  243. const browser2 = await chromium.launch();
  244. const pdfPage = await browser2.newPage();
  245. // Build an HTML page with all screenshots, one per page
  246. const imagesHtml = screenshotPaths.map((p) => {
  247. const imgData = readFileSync(p).toString('base64');
  248. return `<div class="page"><img src="data:image/png;base64,${imgData}" /></div>`;
  249. }).join('\n');
  250. const pdfHtml = `<!DOCTYPE html>
  251. <html>
  252. <head>
  253. <style>
  254. * { margin: 0; padding: 0; }
  255. @page { size: ${VP_WIDTH}px ${VP_HEIGHT}px; margin: 0; }
  256. .page {
  257. width: ${VP_WIDTH}px;
  258. height: ${VP_HEIGHT}px;
  259. page-break-after: always;
  260. overflow: hidden;
  261. }
  262. .page:last-child { page-break-after: auto; }
  263. img {
  264. width: ${VP_WIDTH}px;
  265. height: ${VP_HEIGHT}px;
  266. display: block;
  267. object-fit: contain;
  268. }
  269. </style>
  270. </head>
  271. <body>${imagesHtml}</body>
  272. </html>`;
  273. await pdfPage.setContent(pdfHtml, { waitUntil: 'load' });
  274. await pdfPage.pdf({
  275. path: OUTPUT_PDF,
  276. width: `${VP_WIDTH}px`,
  277. height: `${VP_HEIGHT}px`,
  278. printBackground: true,
  279. margin: { top: 0, right: 0, bottom: 0, left: 0 },
  280. });
  281. await browser2.close();
  282. // Clean up screenshots
  283. screenshotPaths.forEach(p => unlinkSync(p));
  284. console.log(` ✓ PDF saved to: ${OUTPUT_PDF}`);
  285. EXPORT_SCRIPT
  286. # ─── Step 3: Install Playwright in temp directory ──────────
  287. # We install Playwright locally in the temp dir so the Node script can import it.
  288. # This avoids polluting global packages and ensures the script is self-contained.
  289. info "Setting up Playwright (headless browser for screenshots)..."
  290. info "This may take a moment on first run..."
  291. echo ""
  292. cd "$TEMP_DIR"
  293. # Create a minimal package.json so npm install works
  294. cat > "$TEMP_DIR/package.json" << 'PKG'
  295. { "name": "slide-export", "private": true, "type": "module" }
  296. PKG
  297. # Install Playwright into the temp directory
  298. npm install playwright &>/dev/null || {
  299. err "Failed to install Playwright."
  300. err "Try running: npm install playwright"
  301. rm -rf "$TEMP_DIR"
  302. exit 1
  303. }
  304. # Ensure Chromium browser binary is downloaded
  305. npx playwright install chromium 2>/dev/null || {
  306. err "Failed to install Chromium browser for Playwright."
  307. err "Try running manually: npx playwright install chromium"
  308. rm -rf "$TEMP_DIR"
  309. exit 1
  310. }
  311. ok "Playwright ready"
  312. echo ""
  313. # ─── Step 4: Run the export ───────────────────────────────
  314. SCREENSHOT_DIR="$TEMP_DIR/screenshots"
  315. info "Exporting slides to PDF..."
  316. echo ""
  317. # Run from the temp dir so Node can find the locally-installed playwright
  318. if [[ "$COMPACT" == "true" ]]; then
  319. info "Using compact mode (1280×720) for smaller file size"
  320. fi
  321. node "$TEMP_SCRIPT" "$SERVE_DIR" "$HTML_FILENAME" "$OUTPUT_PDF" "$SCREENSHOT_DIR" "$VIEWPORT_W" "$VIEWPORT_H" || {
  322. err "PDF export failed."
  323. rm -rf "$TEMP_DIR"
  324. exit 1
  325. }
  326. # ─── Step 5: Cleanup and success ──────────────────────────
  327. rm -rf "$TEMP_DIR"
  328. echo ""
  329. echo -e "${BOLD}════════════════════════════════════════${NC}"
  330. ok "PDF exported successfully!"
  331. echo ""
  332. echo -e " ${BOLD}File:${NC} $OUTPUT_PDF"
  333. echo ""
  334. FILE_SIZE=$(du -h "$OUTPUT_PDF" | cut -f1 | xargs)
  335. echo " Size: $FILE_SIZE"
  336. echo ""
  337. echo " This PDF works everywhere — email, Slack, Notion, print."
  338. echo " Note: Animations are not preserved (it's a static export)."
  339. echo -e "${BOLD}════════════════════════════════════════${NC}"
  340. echo ""
  341. # Open the PDF automatically
  342. if command -v open &>/dev/null; then
  343. open "$OUTPUT_PDF"
  344. elif command -v xdg-open &>/dev/null; then
  345. xdg-open "$OUTPUT_PDF"
  346. fi