/** * PDF and OG thumbnail generation via Playwright. * * Pipeline (aligned with research-article-template's export-pdf.mjs): * * 1. launch headless Chromium * 2. `page.goto(url)` on the real published page (served by the backend) * - this guarantees the PDF matches exactly what users see, including * scripts that move footnotes/bibliography into the references block. * - a `setContent` fallback is kept for the unit tests that don't spin * up the HTTP server. * 3. Pass A - thumbnail: viewport 1200x630, no print emulation, JPEG screenshot * 4. Pass B - PDF: * a. emulateMedia({ media: 'print' }) <- activates @media print rules * b. setViewportSize to the printable width (so layout matches PDF page) * c. waitForAssets (fonts, images, iframes readyState, rAF x2, stable layout) * d. injectPdfToc (build a linear .pdf-toc from h2/h3 for the PDF only) * e. waitForMermaid (ensure all mermaid diagrams finished rendering) * f. rasterizeMermaid (screenshot pre.mermaid -> ) * g. rasterizeEmbeds (screenshot iframes -> ) * h. page.pdf({ format, margin, printBackground: true }) */ let playwrightAvailable = false; // eslint-disable-next-line @typescript-eslint/no-explicit-any let chromium: any; try { const pw = await import(/* @vite-ignore */ "playwright"); chromium = pw.chromium; playwrightAvailable = true; } catch { console.warn("[publisher] Playwright not installed, PDF/thumbnail generation disabled"); } export function isPdfEnabled(): boolean { return playwrightAvailable && process.env.ENABLE_PDF !== "false"; } export interface GeneratedAssets { pdf: Buffer | null; thumbnail: Buffer | null; } export type PageFormat = "A4" | "Letter"; export interface PdfMargin { top: string; right: string; bottom: string; left: string; } export interface PdfOptions { format?: PageFormat; margin?: PdfMargin; /** * When set, Playwright navigates to this URL instead of calling * `setContent(html)`. Prefer this - the PDF then matches exactly what the * user sees at that URL (scripts that move footnotes/bibliography into the * footer run naturally under `networkidle`). */ url?: string; /** Emitted at each step so the route can stream SSE events. */ onStage?: (stage: string, detail?: Record) => void; } const DEFAULT_MARGIN: PdfMargin = { top: "12mm", right: "16mm", bottom: "16mm", left: "16mm" }; const FORMAT_MM: Record = { A4: { w: 210, h: 297 }, Letter: { w: 215.9, h: 279.4 }, }; function cssLengthToMm(len: string): number { const m = len.trim().match(/^([\d.]+)\s*(mm|cm|in|pt|px)?$/i); if (!m) return 0; const n = parseFloat(m[1]); const unit = (m[2] || "mm").toLowerCase(); switch (unit) { case "mm": return n; case "cm": return n * 10; case "in": return n * 25.4; case "pt": return (n * 25.4) / 72; case "px": return (n * 25.4) / 96; default: return n; } } function computePrintableWidthPx(format: PageFormat, margin: PdfMargin): number { const fmt = FORMAT_MM[format]; const innerMm = fmt.w - cssLengthToMm(margin.left) - cssLengthToMm(margin.right); return Math.max(320, Math.round((innerMm / 25.4) * 96)); } /** * Wait for fonts, images, iframes to load, then two rAFs, then a stable layout. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any async function waitForAssets(page: any, timeoutMs = 10_000): Promise { await page.evaluate(async () => { const d = document as Document & { fonts?: { ready?: Promise } }; if (d.fonts?.ready) { try { await d.fonts.ready; } catch { /* ignore */ } } const imgs = Array.from(document.images).filter((i) => !i.complete); await Promise.all( imgs.map( (i) => new Promise((resolve) => { const done = () => resolve(); i.addEventListener("load", done, { once: true }); i.addEventListener("error", done, { once: true }); setTimeout(done, 5_000); }) ) ); const iframes = Array.from(document.querySelectorAll("iframe")); await Promise.all( iframes.map( (f) => new Promise((resolve) => { const done = () => resolve(); try { if (f.contentDocument?.readyState === "complete") { done(); return; } } catch { /* cross-origin: fall through */ } f.addEventListener("load", done, { once: true }); setTimeout(done, 4_000); }) ) ); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); }); await waitForStableLayout(page, timeoutMs); } /** * Poll scrollHeight every 100ms; return when stable 3 times in a row, * or when timeoutMs is reached. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any async function waitForStableLayout(page: any, timeoutMs = 5_000): Promise { const start = Date.now(); let last = -1; let stable = 0; while (Date.now() - start < timeoutMs) { const h = (await page.evaluate(() => document.documentElement.scrollHeight)) as number; if (h === last) stable += 1; else stable = 0; last = h; if (stable >= 3) return; await new Promise((r) => setTimeout(r, 100)); } } /** * Build a linear .pdf-toc from h2/h3 inside .tiptap, inserted right after * the meta bar so it appears between cover and body in the PDF. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any async function injectPdfToc(page: any): Promise { await page.evaluate(() => { if (document.querySelector(".pdf-toc")) return; const headings = Array.from( document.querySelectorAll(".tiptap h2, .tiptap h3") ).filter((h) => (h.textContent || "").trim().length > 0); if (headings.length === 0) return; const toc = document.createElement("nav"); toc.className = "pdf-toc"; toc.setAttribute("aria-label", "Table of Contents"); const title = document.createElement("h2"); title.className = "pdf-toc__title"; title.textContent = "Table of Contents"; toc.appendChild(title); const ul = document.createElement("ul"); ul.className = "pdf-toc__list"; for (const h of headings) { const li = document.createElement("li"); li.className = h.tagName === "H3" ? "pdf-toc__item pdf-toc__item--sub" : "pdf-toc__item"; const a = document.createElement("a"); const id = h.getAttribute("id"); if (id) a.setAttribute("href", `#${id}`); a.textContent = (h.textContent || "").trim(); li.appendChild(a); ul.appendChild(li); } toc.appendChild(ul); const metaBar = document.querySelector(".meta-container, .hero-meta"); const hero = document.querySelector(".hero"); const anchor = metaBar ?? hero ?? document.querySelector(".content-grid"); if (anchor?.parentNode) { anchor.parentNode.insertBefore(toc, anchor.nextSibling); } else { document.body.insertBefore(toc, document.body.firstChild); } }); } /** * Wait for every `pre.mermaid` element to finish rendering (mermaid marks * processed nodes with `data-processed="true"` and inserts an ). The * diagram runtime is loaded as an ESM module from a CDN, so it typically * completes *after* Playwright's `networkidle` signal fires. * * Resolves early when no mermaid block is present. Silently returns once the * timeout is hit so the pipeline keeps moving rather than failing the whole * publish on a slow CDN. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any async function waitForMermaid(page: any, timeoutMs = 10_000): Promise { try { await page.waitForFunction( () => { const nodes = document.querySelectorAll("pre.mermaid"); if (nodes.length === 0) return true; return Array.from(nodes).every( (n) => (n as HTMLElement).getAttribute("data-processed") === "true" || !!(n as HTMLElement).querySelector("svg"), ); }, { timeout: timeoutMs }, ); } catch { console.warn("[pdf] mermaid did not finish rendering before timeout"); } } /** * Replace each rendered Mermaid block with a PNG so the PDF engine * doesn't try to paginate the SVG (foreignObject labels get chopped across * page breaks and some gradients / markers render blank in print media). * * Runs AFTER `waitForMermaid` so every `pre.mermaid` has its SVG in place. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any async function rasterizeMermaid(page: any, onStage?: PdfOptions["onStage"]): Promise { const handles = await page.$$("pre.mermaid"); const total = handles.length; if (total === 0) return; for (let i = 0; i < handles.length; i += 1) { const pre = handles[i]; onStage?.("rasterize", { kind: "mermaid", index: i + 1, total }); try { await pre.scrollIntoViewIfNeeded({ timeout: 1_000 }); } catch { /* ignore */ } await new Promise((r) => setTimeout(r, 100)); // Skip if the node somehow never rendered an SVG - leaves the original // markup in place so debugging stays possible. const hasSvg = await pre.evaluate((el: HTMLElement) => !!el.querySelector("svg")); if (!hasSvg) continue; const box = await pre.boundingBox(); if (!box || box.width < 10 || box.height < 10) continue; let buffer: Buffer; try { buffer = await pre.screenshot({ type: "png", omitBackground: true }); } catch (err) { console.warn("[pdf] failed to screenshot mermaid, leaving SVG as-is:", (err as Error).message); continue; } const dataUrl = `data:image/png;base64,${buffer.toString("base64")}`; const width = Math.round(box.width); const height = Math.round(box.height); await pre.evaluate( (el: HTMLElement, args: { src: string; w: number; h: number }) => { const img = el.ownerDocument!.createElement("img"); img.src = args.src; img.setAttribute("width", String(args.w)); img.setAttribute("height", String(args.h)); img.className = "mermaid-rasterized"; img.style.width = "100%"; img.style.height = "auto"; img.style.display = "block"; img.setAttribute("alt", "Mermaid diagram"); el.replaceWith(img); }, { src: dataUrl, w: width, h: height }, ); } } /** * Replace each embed iframe (charts, hero banner) with a PNG generated * from a Playwright element screenshot. Chromium paginates iframes poorly * (often cut in half or rendered blank across pages), so we flatten them. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any async function rasterizeEmbeds(page: any, onStage?: PdfOptions["onStage"]): Promise { const handles = await page.$$( ".html-embed-container > iframe, .hero-banner > iframe" ); const total = handles.length; if (total === 0) return; for (let i = 0; i < handles.length; i += 1) { const iframe = handles[i]; onStage?.("rasterize", { kind: "embed", index: i + 1, total }); try { await iframe.scrollIntoViewIfNeeded({ timeout: 1_000 }); } catch { /* ignore */ } await new Promise((r) => setTimeout(r, 150)); const box = await iframe.boundingBox(); if (!box || box.width < 10 || box.height < 10) continue; let buffer: Buffer; try { buffer = await iframe.screenshot({ type: "png", omitBackground: true }); } catch (err) { console.warn("[pdf] failed to screenshot embed, leaving iframe as-is:", (err as Error).message); continue; } const dataUrl = `data:image/png;base64,${buffer.toString("base64")}`; const width = Math.round(box.width); const height = Math.round(box.height); await iframe.evaluate( (el: HTMLIFrameElement, args: { src: string; w: number; h: number }) => { const img = el.ownerDocument!.createElement("img"); img.src = args.src; img.setAttribute("width", String(args.w)); img.setAttribute("height", String(args.h)); img.style.width = "100%"; img.style.height = "auto"; img.style.display = "block"; img.setAttribute("alt", el.getAttribute("title") || "Embedded chart"); el.replaceWith(img); }, { src: dataUrl, w: width, h: height } ); } } /** * Generate PDF and OG thumbnail from the same source HTML. * * Two load strategies are supported: * - `opts.url` (preferred): navigate Playwright to the live published page. * The PDF then matches exactly what users see, and the JS that moves * footnotes/bibliography into `.references-block` runs naturally. * - `html` fallback: `page.setContent(html)` for tests and any call-site * without an HTTP server to hit. * * The PDF honours @media print; the thumbnail uses the default (screen) media. */ export async function generatePdfAndThumbnail( html: string, opts: PdfOptions = {} ): Promise { if (!playwrightAvailable) { return { pdf: null, thumbnail: null }; } const format: PageFormat = opts.format ?? "A4"; const margin: PdfMargin = opts.margin ?? DEFAULT_MARGIN; const onStage = opts.onStage; const browser = await chromium.launch({ args: ["--no-sandbox", "--disable-setuid-sandbox"], }); try { const page = await browser.newPage(); // tsx/esbuild wraps named functions with __name(...) helper calls at // transpile time. When we pass an arrow function to page.evaluate, the // transpiled source is serialized to the browser context where __name is // undefined, crashing every evaluate. Install a no-op stub so those calls // pass through unchanged. We pass the script as a raw string so esbuild // doesn't touch it (passing a function would re-introduce the same bug // inside the init script itself). await page.addInitScript({ content: "if (typeof window.__name !== 'function') { window.__name = function (fn) { return fn; }; }", }); onStage?.("load", opts.url ? { url: opts.url } : undefined); if (opts.url) { await page.goto(opts.url, { waitUntil: "networkidle", timeout: 30_000 }); } else { await page.setContent(html, { waitUntil: "networkidle" }); } // Defensive: guarantee __name exists before the first evaluate, even if // addInitScript didn't fire in time for this document. await page.evaluate( "window.__name = window.__name || function (fn) { return fn; };" ); await waitForAssets(page, 8_000); onStage?.("thumbnail"); await page.setViewportSize({ width: 1200, height: 630 }); await page.evaluate(() => window.scrollTo(0, 0)); await new Promise((r) => setTimeout(r, 200)); const thumbnail = await page.screenshot({ type: "jpeg", quality: 85, clip: { x: 0, y: 0, width: 1200, height: 630 }, }); onStage?.("pdf-setup"); await page.emulateMedia({ media: "print" }); const printableWidth = computePrintableWidthPx(format, margin); await page.setViewportSize({ width: printableWidth, height: 1400 }); await page.evaluate(() => window.scrollTo(0, 0)); await waitForAssets(page, 8_000); onStage?.("pdf-toc"); await injectPdfToc(page); // Mermaid diagrams must be fully rendered before we screenshot them. // `networkidle` fires before the ESM module from the CDN finishes running. await waitForMermaid(page, 10_000); await rasterizeMermaid(page, onStage); await rasterizeEmbeds(page, onStage); await waitForStableLayout(page, 3_000); onStage?.("pdf-render"); const pdf = await page.pdf({ format, printBackground: true, displayHeaderFooter: false, preferCSSPageSize: false, margin, }); return { pdf: Buffer.from(pdf), thumbnail: Buffer.from(thumbnail), }; } finally { await browser.close(); } }