carbon-tokenization / backend /src /publisher /pdf-generator.ts
tfrere's picture
tfrere HF Staff
refactor(backend/publisher): transformer pipeline + shared registry + shiki highlighting
7843436
Raw
History Blame Contribute Delete
16.1 kB
/**
* 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 -> <img data-URL>)
* g. rasterizeEmbeds (screenshot iframes -> <img data-URL>)
* 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<string, unknown>) => void;
}
const DEFAULT_MARGIN: PdfMargin = { top: "12mm", right: "16mm", bottom: "16mm", left: "16mm" };
const FORMAT_MM: Record<PageFormat, { w: number; h: number }> = {
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<void> {
await page.evaluate(async () => {
const d = document as Document & { fonts?: { ready?: Promise<unknown> } };
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<void>((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<void>((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<void>((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<void> {
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<void> {
await page.evaluate(() => {
if (document.querySelector(".pdf-toc")) return;
const headings = Array.from(
document.querySelectorAll<HTMLElement>(".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 <svg>). 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<void> {
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 <img> 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<void> {
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 <img> 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<void> {
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<GeneratedAssets> {
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();
}
}