| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| let playwrightAvailable = false; |
| |
| let chromium: any; |
|
|
| try { |
| const pw = await import( "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; |
| |
| |
| |
| |
| |
| |
| url?: string; |
| |
| 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)); |
| } |
|
|
| |
| |
| |
| |
| 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 { } |
| } |
| 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 { } |
| f.addEventListener("load", done, { once: true }); |
| setTimeout(done, 4_000); |
| }) |
| ) |
| ); |
| await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); |
| }); |
| await waitForStableLayout(page, timeoutMs); |
| } |
|
|
| |
| |
| |
| |
| |
| 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)); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| 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); |
| } |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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"); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| 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 { } |
| await new Promise((r) => setTimeout(r, 100)); |
|
|
| |
| |
| 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 }, |
| ); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| 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 { } |
| 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 } |
| ); |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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(); |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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" }); |
| } |
| |
| |
| 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); |
|
|
| |
| |
| 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(); |
| } |
| } |
|
|