src/lib/pdf.ts
Rama main · réplica de lectura del repositorio original.
import { existsSync } from "node:fs";
import { chromium, type Browser } from "playwright-core";
/**
* Server-side PDF rendering.
*
* The print page at /print/[id] stays exactly as it was — it is why the output
* is real selectable text rather than an image, and it is the fallback whenever
* this module reports itself unavailable. All this does is drive a headless
* browser to that same page and capture the bytes, so there is one renderer,
* not two, and a PDF can never drift from what the editor shows.
*
* `playwright-core` is deliberate: unlike `playwright` it downloads no browser
* on install, so adding it cannot slow or break a deploy. It uses whatever
* Chromium the host already has. If there is none, `pdfRenderer()` returns
* `{ available: false }` and every caller falls back to the print dialog — the
* app is never worse off than before this file existed.
*/
/**
* Where a Chromium might live. PDF_CHROMIUM_PATH is checked first so a host
* with an unusual layout can point at one; it is not required anywhere, and
* self-hosting still needs only DATABASE_URL.
*/
const CANDIDATE_PATHS = [
process.env.PDF_CHROMIUM_PATH,
process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/opt/pw-browsers/chromium/chrome-linux/chrome",
"/root/.cache/ms-playwright/chromium/chrome-linux/chrome",
];
/** Resolved once per process; finding it is a handful of stat calls. */
let cachedPath: string | null | undefined;
export function chromiumPath(): string | null {
if (cachedPath !== undefined) return cachedPath;
for (const candidate of CANDIDATE_PATHS) {
if (candidate && existsSync(candidate)) {
cachedPath = candidate;
return cachedPath;
}
}
// Playwright's own install lays browsers out under a versioned directory.
const roots = [process.env.PLAYWRIGHT_BROWSERS_PATH, "/root/.cache/ms-playwright"].filter(
Boolean,
) as string[];
for (const root of roots) {
for (const suffix of ["chrome-linux/chrome", "chrome-linux/headless_shell"]) {
// A glob would need a directory read; a few explicit probes are cheaper
// and this only ever runs once.
for (const version of ["", "-1194", "-1180", "-1160", "-1148"]) {
const candidate = `${root}/chromium${version}/${suffix}`;
if (existsSync(candidate)) {
cachedPath = candidate;
return cachedPath;
}
}
}
}
cachedPath = null;
return cachedPath;
}
export function pdfRenderingAvailable() {
return chromiumPath() !== null;
}
export type PdfResult = {
bytes: Buffer;
/** Page count, read back off the produced file rather than estimated. */
pages: number;
};
/**
* Render a URL of this app to PDF, authenticated as the given session token.
*
* The token is a short-lived row in the ordinary Session table rather than a
* new kind of credential: the print page keeps its `requireUser()` guard, and
* nothing new is exempt from authentication.
*/
export async function renderPdf(input: {
url: string;
sessionCookie: { name: string; value: string; domain: string; secure: boolean };
/**
* The element that proves the page rendered a document rather than a redirect
* to the login screen. A selector rather than a fixed class because letters
* print through this same function and are not resumes; the default keeps
* every existing caller reading exactly as it did.
*/
marker?: string;
}): Promise<PdfResult> {
const executablePath = chromiumPath();
if (!executablePath) {
throw new Error(
"No Chromium on this host, so the server cannot render a PDF. Use the print page instead.",
);
}
let browser: Browser | null = null;
try {
browser = await chromium.launch({
executablePath,
// --no-sandbox is required in containers, which is where this runs.
args: ["--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu"],
});
const context = await browser.newContext();
await context.addCookies([
{
name: input.sessionCookie.name,
value: input.sessionCookie.value,
domain: input.sessionCookie.domain,
path: "/",
httpOnly: true,
secure: input.sessionCookie.secure,
sameSite: "Lax",
},
]);
const page = await context.newPage();
const response = await page.goto(input.url, { waitUntil: "networkidle", timeout: 30_000 });
if (!response || !response.ok()) {
throw new Error(`The print page answered ${response?.status() ?? "nothing"}.`);
}
// The page must have actually rendered a document, not a redirect to login.
const paper = await page.locator(input.marker ?? ".resume-paper").count();
if (paper === 0) throw new Error("The print page rendered no document.");
const bytes = await page.pdf({
format: "Letter",
printBackground: true,
// No margin stated here: the document's own @page rule carries it, and
// preferCSSPageSize is what makes Chromium honour that rather than its
// own default. Setting one here as well is a second place for the margin
// to be decided, which is how the PDF and the print page drift apart.
preferCSSPageSize: true,
});
return { bytes, pages: countPages(bytes) };
} finally {
await browser?.close().catch(() => {});
}
}
/** Count `/Type /Page` objects. Crude, but read off the real file. */
function countPages(bytes: Buffer) {
const matches = bytes.toString("latin1").match(/\/Type\s*\/Page[^s]/g);
return Math.max(1, matches?.length ?? 1);
}