/**
* lib/pdf/export.ts
*
* HTML → PDF export using Puppeteer (screenshot each slide) + pdf-lib (assemble PDF).
*
* Algorithm:
* 1. Launch headless Chrome at 1920×1080 (canvas size)
* 2. Scan HTML for external http(s) image URLs, download them to a temp dir,
* rewrite the HTML to use file:// local paths — avoids CDN/CORS/headless issues
* 3. Navigate to the patched HTML file
* 4. For each .slide: force .reveal.visible, wait 800ms, screenshot .slide-canvas
* or slide-as-canvas fallback using offsetParent-chain absolute coordinates
* 5. Assemble screenshots into a multi-page PDF (16:9, 1920×1080pt per page) via pdf-lib
* 6. Write PDF alongside the HTML file (same directory, .html → .pdf)
* 7. Clean up temp dir
*
* Output path: replaces the .html extension with .pdf, same directory as input.
*/
import { PDFDocument } from "pdf-lib"
import {
existsSync,
writeFileSync,
readFileSync,
mkdirSync,
rmSync,
} from "fs"
import { resolve, dirname, basename, join, extname } from "path"
import { pathToFileURL } from "url"
import { randomBytes } from "crypto"
import { launchChrome } from "../browser/chrome"
import { detectDeckHtml } from "../html-export/deck-detect"
import { exportSinglePageHtmlPdf } from "../html-export"
import { withExportBaseHref } from "../export/html"
// ── Constants ────────────────────────────────────────────────────────────────
/** Canonical slide canvas dimensions — must match the design system */
const CANVAS_W = 1920
const CANVAS_H = 1080
/** Mime type → file extension mapping for downloaded images */
const MIME_TO_EXT: Record = {
"image/jpeg": ".jpg",
"image/jpg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
"image/svg+xml": ".svg",
"image/avif": ".avif",
}
const IMAGE_EXTS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".avif", ".bmp"])
const EXT_TO_MIME: Record = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".svg": "image/svg+xml",
".avif": "image/avif",
".bmp": "image/bmp",
}
// ── Helpers ──────────────────────────────────────────────────────────────────
/** Derive output PDF path from input HTML path (same dir, .html → .pdf) */
export function derivePdfPath(htmlFilePath: string): string {
const abs = resolve(htmlFilePath)
const dir = dirname(abs)
const name = basename(abs).replace(/\.html?$/i, "")
return join(dir, `${name}.pdf`)
}
/**
* Download all external http(s) images found in the HTML to a temp directory,
* rewrite their URLs to file:// local paths, and return the patched HTML + temp dir.
*
* On any per-image failure (network error, non-200, timeout) the original URL is
* preserved so the export degrades gracefully (blank image area) rather than failing.
*/
async function localizeExternalImages(
htmlContent: string,
tmpDir: string
): Promise {
// Extract all unique http(s) URLs that appear in src="..." or url("...") contexts
const urlPattern = /(?:src=["']|url\(["']?)(https?:\/\/[^"')>\s]+)/g
const uniqueUrls = new Set()
let match: RegExpExecArray | null
while ((match = urlPattern.exec(htmlContent)) !== null) {
uniqueUrls.add(match[1])
}
if (uniqueUrls.size === 0) return htmlContent
// Download each URL in parallel (10s timeout per image)
const urlToLocal = new Map()
await Promise.allSettled(
Array.from(uniqueUrls).map(async (url, i) => {
try {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), 10000)
const res = await fetch(url, {
signal: controller.signal,
headers: {
// Use a real browser UA to avoid CDN blocking headless/bot requests
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
},
})
clearTimeout(timer)
if (!res.ok) return // non-200 → keep original URL
const contentType = res.headers.get("content-type") ?? ""
const mimeBase = contentType.split(";")[0].trim().toLowerCase()
// Derive extension: prefer from URL, fall back to Content-Type
let ext = extname(new URL(url).pathname).toLowerCase()
if (!ext || ext.length > 6) {
ext = MIME_TO_EXT[mimeBase] ?? ".bin"
}
const localPath = join(tmpDir, `img-${i}${ext}`)
const buf = new Uint8Array(await res.arrayBuffer())
writeFileSync(localPath, buf)
urlToLocal.set(url, pathToFileURL(localPath).href)
} catch {
// Network error or timeout — leave original URL, Chrome will show broken image
}
})
)
// Replace all occurrences of each downloaded URL in the HTML
let patched = htmlContent
for (const [original, local] of urlToLocal) {
// Escape special regex chars in the URL
const escaped = original.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
patched = patched.replace(new RegExp(escaped, "g"), local)
}
return patched
}
function isLocalImageRef(ref: string): boolean {
const pathPart = ref.split(/[?#]/)[0]
return IMAGE_EXTS.has(extname(pathPart).toLowerCase())
}
export function extractImageAssetRefsForPdf(htmlContent: string): string[] {
const assetRefPattern = /\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))|url\(\s*(?:"([^"]*)"|'([^']*)'|([^\s)]+))\s*\)/g
const refs = new Set()
let match: RegExpExecArray | null
while ((match = assetRefPattern.exec(htmlContent)) !== null) {
const ref = match.slice(1).find((value): value is string => value !== undefined)
if (ref) refs.add(ref.trim())
}
return Array.from(refs)
}
async function toDataUrlFromRef(ref: string, baseDir: string): Promise {
if (!ref || ref.startsWith("data:") || ref.startsWith("blob:") || ref.startsWith("#")) {
return null
}
try {
if (ref.startsWith("http://") || ref.startsWith("https://") || ref.startsWith("//") || ref.startsWith("file://")) {
return null
}
let filePath: string | null = null
if (isLocalImageRef(ref)) {
filePath = resolve(baseDir, decodeURI(ref.split(/[?#]/)[0]))
}
if (!filePath || !existsSync(filePath)) return null
const ext = extname(filePath).toLowerCase()
const mime = EXT_TO_MIME[ext]
if (!mime) return null
const buf = readFileSync(filePath)
return `data:${mime};base64,${buf.toString("base64")}`
} catch {
return null
}
}
async function prepareSlidesForExport(page: any): Promise {
await page.evaluate((canvasWidth: number, canvasHeight: number) => {
document.documentElement.style.scrollSnapType = "none"
document.documentElement.style.overflow = "visible"
document.body.style.overflow = "visible"
document.body.style.margin = "0"
const style = document.createElement("style")
style.setAttribute("data-revela-export-style", "true")
style.textContent = `
html, body { scroll-snap-type: none !important; overflow: visible !important; }
.slide {
width: ${canvasWidth}px !important;
min-width: ${canvasWidth}px !important;
height: ${canvasHeight}px !important;
min-height: ${canvasHeight}px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
overflow: hidden !important;
scroll-snap-align: none !important;
}
.slide-canvas {
width: ${canvasWidth}px !important;
height: ${canvasHeight}px !important;
transform: none !important;
transform-origin: center center !important;
}
`
document.head.appendChild(style)
document.querySelectorAll(".slide-canvas").forEach((canvas) => {
canvas.style.transform = "none"
canvas.style.transformOrigin = "center center"
})
}, CANVAS_W, CANVAS_H)
}
export async function inlineImageAssetsForPdf(htmlContent: string, htmlFilePath: string): Promise {
const baseDir = dirname(resolve(htmlFilePath))
const refs = extractImageAssetRefsForPdf(htmlContent)
if (refs.length === 0) return htmlContent
const replacements = new Map()
await Promise.allSettled(
refs.map(async (ref) => {
const dataUrl = await toDataUrlFromRef(ref, baseDir)
if (dataUrl) replacements.set(ref, dataUrl)
})
)
let patched = htmlContent
for (const [original, replacement] of replacements) {
const escaped = original.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
patched = patched.replace(new RegExp(escaped, "g"), replacement)
}
return patched
}
// ── Main export ──────────────────────────────────────────────────────────────
export interface ExportResult {
outputPath: string
slideCount: number
durationMs: number
exportMode: "deck" | "single-page-html"
deckDetection?: {
isDeck: boolean
slideCount: number
reason: string
}
selector?: string
pngPath?: string
warnings?: string[]
}
export interface ExportPngResult {
outputDir: string
files: string[]
slideCount: number
durationMs: number
exportMode: "deck"
}
export interface ExportPngOptions {
outputDir?: string
}
/**
* Export an HTML slide deck to PDF.
*
* @param htmlFilePath - Absolute or relative path to the HTML file.
* @returns ExportResult with output path, slide count, and duration.
*/
export async function exportToPdf(htmlFilePath: string): Promise {
const startMs = Date.now()
const abs = resolve(htmlFilePath)
const detection = await detectDeckHtml(abs)
if (detection.isDeck) {
const result = await exportDeckToPdf(abs)
return { ...result, exportMode: "deck", deckDetection: detection, durationMs: Date.now() - startMs }
}
const result = await exportSinglePageHtmlPdf(abs, { outputPath: derivePdfPath(abs) })
return {
outputPath: result.outputPath,
slideCount: 1,
durationMs: Date.now() - startMs,
exportMode: "single-page-html",
deckDetection: detection,
selector: result.selector,
pngPath: result.pngPath,
warnings: result.warnings,
}
}
export async function exportDeckToPdf(htmlFilePath: string): Promise> {
const startMs = Date.now()
const abs = resolve(htmlFilePath)
if (!existsSync(abs)) {
throw new Error(`File not found: ${abs}`)
}
if (!/\.html?$/i.test(abs)) {
throw new Error(`Not an HTML file: ${abs}`)
}
const outputPath = derivePdfPath(abs)
const screenshots = await screenshotDeckSlides(abs, "pdf")
// ── Step 3: Assemble PDF with pdf-lib ─────────────────────────────────────
const pdfDoc = await PDFDocument.create()
for (const pngBuf of screenshots) {
const pngImage = await pdfDoc.embedPng(new Uint8Array(pngBuf))
// Each page is exactly the canvas size (points = pixels at 1:1 for screen PDF)
const page = pdfDoc.addPage([CANVAS_W, CANVAS_H])
page.drawImage(pngImage, {
x: 0,
y: 0,
width: CANVAS_W,
height: CANVAS_H,
})
}
const pdfBytes = await pdfDoc.save()
writeFileSync(outputPath, pdfBytes)
return {
outputPath,
slideCount: screenshots.length,
durationMs: Date.now() - startMs,
}
}
export async function exportDeckToPng(htmlFilePath: string, options: ExportPngOptions = {}): Promise {
const startMs = Date.now()
const abs = resolve(htmlFilePath)
if (!existsSync(abs)) {
throw new Error(`File not found: ${abs}`)
}
if (!/\.html?$/i.test(abs)) {
throw new Error(`Not an HTML file: ${abs}`)
}
const outputDir = options.outputDir ?? join(dirname(abs), `${basename(abs).replace(/\.html?$/i, "")}-png`)
mkdirSync(outputDir, { recursive: true })
const screenshots = await screenshotDeckSlides(abs, "png")
const files: string[] = []
screenshots.forEach((pngBuf, index) => {
const outputPath = join(outputDir, `slide-${String(index + 1).padStart(3, "0")}.png`)
writeFileSync(outputPath, new Uint8Array(pngBuf))
files.push(outputPath)
})
return {
outputDir,
files,
slideCount: screenshots.length,
durationMs: Date.now() - startMs,
exportMode: "deck",
}
}
async function screenshotDeckSlides(htmlFilePath: string, label: "pdf" | "png"): Promise {
const abs = resolve(htmlFilePath)
if (!existsSync(abs)) {
throw new Error(`File not found: ${abs}`)
}
if (!/\.html?$/i.test(abs)) {
throw new Error(`Not an HTML file: ${abs}`)
}
// ── Step 1: Download external images and rewrite HTML ─────────────────────
const tmpDir = join("/tmp", `revela-${label}-${randomBytes(6).toString("hex")}`)
mkdirSync(tmpDir, { recursive: true })
let tmpHtmlPath: string
try {
const originalHtml = readFileSync(abs, "utf-8")
const localizedHtml = await localizeExternalImages(originalHtml, tmpDir)
const patchedHtml = withExportBaseHref(await inlineImageAssetsForPdf(localizedHtml, abs), abs)
tmpHtmlPath = join(tmpDir, "index.html")
writeFileSync(tmpHtmlPath, patchedHtml, "utf-8")
} catch (err) {
// If patching fails for any reason, fall back to original file
tmpHtmlPath = abs
}
const fileUrl = pathToFileURL(tmpHtmlPath).href
// ── Step 2: Launch Puppeteer and screenshot each slide ────────────────────
const browser = await launchChrome({ width: CANVAS_W, height: CANVAS_H, allowFileAccess: true })
let screenshots: Buffer[] = []
try {
const page = await browser.newPage()
// Set exact canvas viewport so scale === 1
await page.setViewport({ width: CANVAS_W, height: CANVAS_H })
// All images are now local file:// — no external requests needed.
// domcontentloaded is sufficient; networkidle2 would waste time.
await page.goto(fileUrl, { waitUntil: "domcontentloaded", timeout: 30000 })
// Wait for fonts (Google Fonts may still be external) and CSS animations to settle
await new Promise((r) => setTimeout(r, 2000))
await prepareSlidesForExport(page)
const slideCount: number = await page.evaluate(
() => document.querySelectorAll(".slide").length
)
if (slideCount === 0) {
throw new Error(
"No .slide elements found in the HTML file.\n" +
"Make sure this is a revela-generated slide deck."
)
}
// Screenshot each slide individually
for (let idx = 0; idx < slideCount; idx++) {
// Force reveal animations (no scrollIntoView — we use absolute coords)
await page.evaluate((i: number) => {
const slide = document.querySelectorAll(".slide")[i] as HTMLElement | null
if (!slide) return
slide.querySelectorAll(".reveal").forEach((el) => el.classList.add("visible"))
}, idx)
// Wait for CSS transitions and JS rendering (ECharts animations, etc.)
await new Promise((r) => setTimeout(r, 800))
const target = await page.$(`.slide:nth-of-type(${idx + 1}) > .slide-canvas`)
?? await page.$(`.slide:nth-of-type(${idx + 1})`)
const box = target ? await target.boundingBox() : null
if (target && box && box.width > 0 && box.height > 0) {
const buf = await target.screenshot({ type: "png" })
screenshots.push(Buffer.from(buf as Uint8Array))
} else {
const buf = await page.screenshot({ type: "png" })
screenshots.push(Buffer.from(buf as Uint8Array))
}
}
} finally {
await browser.close()
// Clean up temp dir (downloaded images + patched HTML)
try {
rmSync(tmpDir, { recursive: true, force: true })
} catch {
// Non-fatal — /tmp will be cleaned by OS eventually
}
}
return screenshots
}