/**
* 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