feat: 实现 Chromium PDF 导出与精确预览

This commit is contained in:
SkyJourney
2026-07-26 13:21:17 +08:00
parent d62552384f
commit d1b5880a5b
26 changed files with 3328 additions and 316 deletions
+268 -24
View File
@@ -1,12 +1,21 @@
import { fileURLToPath } from "node:url";
import { resolve } from "node:path";
import { performance } from "node:perf_hooks";
import Fastify from "fastify";
import {
EXPORT_CONFIG_VERSION,
defaultExportConfig,
exportConfigSchema,
supportedPaperFormats
} from "@md-to-pdf/core";
import { RENDERER_VERSION, renderMarkdown } from "@md-to-pdf/renderer";
import {
createPdfGenerator,
PdfEngineClosedError,
PdfEngineOverloadedError,
PdfRenderTimeoutError,
type PdfGenerator
} from "./pdf-engine.js";
import { createThemeRegistry } from "./theme-registry.js";
interface RenderRequestBody {
@@ -14,11 +23,115 @@ interface RenderRequestBody {
language?: unknown;
}
interface PdfRequestBody extends RenderRequestBody {
fileName?: unknown;
exportConfig?: unknown;
}
const projectRoot = fileURLToPath(new URL("../../../", import.meta.url));
const maximumMarkdownLength = 1_500_000;
function validateMarkdownRequest(
body: RenderRequestBody | undefined
):
| { markdown: string; language?: string }
| { statusCode: 400 | 413; error: string; message: string } {
const { markdown, language } = body ?? {};
if (typeof markdown !== "string") {
return {
statusCode: 400,
error: "INVALID_MARKDOWN",
message: "markdown 必须是字符串"
};
}
if (markdown.length > maximumMarkdownLength) {
return {
statusCode: 413,
error: "MARKDOWN_TOO_LARGE",
message: "Markdown 内容不能超过 1.5 MB"
};
}
if (language !== undefined && typeof language !== "string") {
return {
statusCode: 400,
error: "INVALID_LANGUAGE",
message: "language 必须是字符串"
};
}
return {
markdown,
...(typeof language === "string" ? { language } : {})
};
}
function encodeRfc5987(value: string) {
return encodeURIComponent(value).replace(
/[!'()*]/g,
(character) =>
`%${character.charCodeAt(0).toString(16).toUpperCase()}`
);
}
export function createPdfFileName(fileName: unknown) {
const source =
typeof fileName === "string"
? fileName.split(/[\\/]/).at(-1) ?? ""
: "";
const stem = source
.replace(/\.(?:md|markdown)$/i, "")
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_")
.replace(/[.\s]+$/g, "")
.trim();
return `${stem || "文档"}.pdf`;
}
export function createPdfContentDisposition(fileName: string) {
return (
`attachment; filename="document.pdf"; ` +
`filename*=UTF-8''${encodeRfc5987(fileName)}`
);
}
export interface BuildAppOptions {
localThemeRoot?: string;
logger?: boolean;
pdfGenerator?: PdfGenerator;
prewarmPdfBrowser?: boolean;
}
function milliseconds(value: number) {
return Math.max(0, value).toFixed(1);
}
function createPdfServerTiming(
themeMs: number,
markdownMs: number,
generated: Awaited<ReturnType<PdfGenerator["generate"]>>,
requestMs: number
) {
const { timings } = generated;
return [
`theme;dur=${milliseconds(themeMs)}`,
`markdown;dur=${milliseconds(markdownMs)}`,
`queue;dur=${milliseconds(timings.queueMs)}`,
`browser;dur=${milliseconds(timings.browserMs)}`,
`context;dur=${milliseconds(timings.contextMs)}`,
`navigation;dur=${milliseconds(timings.navigationMs)}`,
`document-render;dur=${milliseconds(timings.documentRenderMs)}`,
`runtime-setup;dur=${milliseconds(timings.runtimeSetupMs)}`,
`mermaid;dur=${milliseconds(timings.mermaidMs)}`,
`mermaid-fit;dur=${milliseconds(timings.mermaidFitMs)}`,
`mermaid-conversion;dur=${milliseconds(
timings.mermaidConversionMs
)}`,
`resources;dur=${milliseconds(timings.resourceWaitMs)}`,
`pagination;dur=${milliseconds(timings.paginationMs)}`,
`pagination-finalize;dur=${milliseconds(timings.finalizeMs)}`,
`pdf-print;dur=${milliseconds(timings.pdfPrintMs)}`,
`total;dur=${milliseconds(requestMs)}`
].join(", ");
}
export function buildApp(options: BuildAppOptions = {}) {
@@ -31,6 +144,22 @@ export function buildApp(options: BuildAppOptions = {}) {
localRoot:
options.localThemeRoot ?? resolve(projectRoot, ".local", "themes")
});
const pdfGenerator =
options.pdfGenerator ?? createPdfGenerator();
if (options.prewarmPdfBrowser) {
app.addHook("onReady", async () => {
try {
await pdfGenerator.warmup?.();
} catch (error) {
app.log.warn({ error }, "PDF browser warmup failed");
}
});
}
app.addHook("onClose", async () => {
await pdfGenerator.close();
});
app.get("/api/health", async () => ({
status: "ok",
@@ -47,9 +176,10 @@ export function buildApp(options: BuildAppOptions = {}) {
"export-config",
"theme-manifest",
"markdown-render",
"html-preview"
"html-preview",
"pdf-export"
],
planned: ["pdf-export"]
planned: []
}));
app.get("/api/themes", async () => ({
@@ -116,34 +246,148 @@ export function buildApp(options: BuildAppOptions = {}) {
app.post<{ Body: RenderRequestBody }>(
"/api/render",
async (request, reply) => {
const { markdown, language } = request.body ?? {};
if (typeof markdown !== "string") {
return reply.code(400).send({
error: "INVALID_MARKDOWN",
message: "markdown 必须是字符串"
const validated = validateMarkdownRequest(request.body);
if ("statusCode" in validated) {
return reply.code(validated.statusCode).send({
error: validated.error,
message: validated.message
});
}
if (markdown.length > 1_500_000) {
return reply.code(413).send({
error: "MARKDOWN_TOO_LARGE",
message: "Markdown 内容不能超过 1.5 MB"
});
}
if (language !== undefined && typeof language !== "string") {
return reply.code(400).send({
error: "INVALID_LANGUAGE",
message: "language 必须是字符串"
});
}
return renderMarkdown(markdown, {
...(typeof language === "string" ? { language } : {})
return renderMarkdown(validated.markdown, {
...(validated.language ? { language: validated.language } : {})
});
}
);
app.post<{ Body: PdfRequestBody }>(
"/api/pdf",
async (request, reply) => {
const requestStartedAt = performance.now();
const validated = validateMarkdownRequest(request.body);
if ("statusCode" in validated) {
return reply.code(validated.statusCode).send({
error: validated.error,
message: validated.message
});
}
const parsedConfig = exportConfigSchema.safeParse(
request.body?.exportConfig
);
if (!parsedConfig.success) {
return reply.code(400).send({
error: "INVALID_EXPORT_CONFIG",
message: "导出配置无效",
issues: parsedConfig.error.issues
});
}
if (
request.body?.fileName !== undefined &&
(typeof request.body.fileName !== "string" ||
request.body.fileName.length > 500)
) {
return reply.code(400).send({
error: "INVALID_FILE_NAME",
message: "fileName 必须是长度不超过 500 的字符串"
});
}
const themeStartedAt = performance.now();
const themeCss = await themes.getCss(parsedConfig.data.themeId);
const themeMs = performance.now() - themeStartedAt;
if (themeCss === undefined) {
return reply.code(404).send({
error: "THEME_NOT_FOUND",
message: "未找到指定主题"
});
}
const markdownStartedAt = performance.now();
const rendered = renderMarkdown(validated.markdown, {
...(validated.language ? { language: validated.language } : {})
});
const markdownMs = performance.now() - markdownStartedAt;
try {
const generated = await pdfGenerator.generate({
articleHtml: rendered.articleHtml,
fileName:
typeof request.body.fileName === "string"
? request.body.fileName
: "文档.md",
metadata: rendered.metadata,
features: rendered.features,
themeCss,
exportConfig: parsedConfig.data
});
const pdfFileName = createPdfFileName(request.body.fileName);
const requestMs = performance.now() - requestStartedAt;
const serverTiming = createPdfServerTiming(
themeMs,
markdownMs,
generated,
requestMs
);
request.log.info(
{
pageCount: generated.pageCount,
timings: {
themeMs,
markdownMs,
...generated.timings,
requestMs
}
},
"PDF generated"
);
return reply
.header("content-type", "application/pdf")
.header(
"content-disposition",
createPdfContentDisposition(pdfFileName)
)
.header("cache-control", "no-store")
.header("server-timing", serverTiming)
.header("x-pdf-page-count", String(generated.pageCount))
.header(
"x-mermaid-error-count",
String(generated.mermaidErrors.length)
)
.send(generated.pdf);
} catch (error) {
if (error instanceof PdfEngineOverloadedError) {
return reply
.code(503)
.header("retry-after", "5")
.send({
error: "PDF_QUEUE_FULL",
message: error.message
});
}
if (error instanceof PdfRenderTimeoutError) {
return reply.code(504).send({
error: "PDF_RENDER_TIMEOUT",
message: "PDF 生成超时,请缩短文档或稍后重试"
});
}
if (error instanceof PdfEngineClosedError) {
return reply.code(503).send({
error: "PDF_ENGINE_UNAVAILABLE",
message: "PDF 生成服务暂不可用"
});
}
request.log.error({ error }, "PDF generation failed");
return reply.code(500).send({
error: "PDF_GENERATION_FAILED",
message: "PDF 生成失败"
});
}
}
);
return app;
}
+3 -1
View File
@@ -2,7 +2,9 @@ import { buildApp } from "./app.js";
const port = Number.parseInt(process.env.PORT ?? "3001", 10);
const host = process.env.HOST ?? "0.0.0.0";
const app = buildApp();
const app = buildApp({
prewarmPdfBrowser: true
});
async function start() {
try {
+415
View File
@@ -0,0 +1,415 @@
import { chromium, type Browser } from "playwright";
import { performance } from "node:perf_hooks";
import type { ExportConfig } from "@md-to-pdf/core";
export interface PdfRenderPayload {
articleHtml: string;
fileName: string;
metadata: {
title: string;
author: string;
subject: string;
keywords: string[];
language: string;
};
features: string[];
themeCss: string;
exportConfig: ExportConfig;
}
export interface PdfGenerationResult {
pdf: Buffer;
pageCount: number;
mermaidErrors: string[];
timings: PdfGenerationTimings;
}
export interface PdfGenerationTimings {
queueMs: number;
browserMs: number;
contextMs: number;
navigationMs: number;
documentRenderMs: number;
runtimeSetupMs: number;
mermaidMs: number;
mermaidFitMs: number;
mermaidConversionMs: number;
resourceWaitMs: number;
paginationMs: number;
finalizeMs: number;
runtimeTotalMs: number;
pdfPrintMs: number;
totalMs: number;
}
export interface PdfGenerator {
generate(payload: PdfRenderPayload): Promise<PdfGenerationResult>;
warmup?(): Promise<void>;
close(): Promise<void>;
}
export interface PdfEngineOptions {
renderOrigin?: string;
concurrency?: number;
maxQueue?: number;
timeoutMs?: number;
launchBrowser?: () => Promise<Browser>;
}
interface PagedRuntimeResult {
pageCount: number;
contentHeight: number;
mermaidErrors: string[];
timings: {
setupMs: number;
mermaidMs: number;
mermaidFitMs: number;
mermaidConversionMs: number;
resourceWaitMs: number;
paginationMs: number;
finalizeMs: number;
totalMs: number;
};
}
interface PdfDocumentResult
extends Omit<PdfGenerationResult, "timings"> {
timings: Pick<
PdfGenerationTimings,
| "navigationMs"
| "documentRenderMs"
| "runtimeSetupMs"
| "mermaidMs"
| "mermaidFitMs"
| "mermaidConversionMs"
| "resourceWaitMs"
| "paginationMs"
| "finalizeMs"
| "runtimeTotalMs"
| "pdfPrintMs"
>;
}
export class PdfEngineOverloadedError extends Error {
constructor() {
super("PDF 生成队列已满");
this.name = "PdfEngineOverloadedError";
}
}
export class PdfEngineClosedError extends Error {
constructor() {
super("PDF 生成服务已关闭");
this.name = "PdfEngineClosedError";
}
}
export class PdfRenderTimeoutError extends Error {
constructor(timeoutMs: number) {
super(`PDF 生成超过 ${timeoutMs}ms`);
this.name = "PdfRenderTimeoutError";
}
}
export class ConcurrencyGate {
private active = 0;
private readonly queue: Array<() => void> = [];
constructor(
private readonly limit: number,
private readonly maxQueue: number
) {
if (!Number.isInteger(limit) || limit < 1) {
throw new Error("PDF 并发数必须是正整数");
}
if (!Number.isInteger(maxQueue) || maxQueue < 0) {
throw new Error("PDF 排队上限必须是非负整数");
}
}
async run<T>(task: () => Promise<T>) {
await this.acquire();
try {
return await task();
} finally {
this.release();
}
}
private acquire() {
if (this.active < this.limit) {
this.active += 1;
return Promise.resolve();
}
if (this.queue.length >= this.maxQueue) {
return Promise.reject(new PdfEngineOverloadedError());
}
return new Promise<void>((resolve) => {
this.queue.push(() => {
this.active += 1;
resolve();
});
});
}
private release() {
this.active -= 1;
this.queue.shift()?.();
}
}
function normalizeRenderOrigin(value: string) {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("PDF 渲染地址必须使用 HTTP 或 HTTPS");
}
return url.origin;
}
export function isAllowedPdfRequestUrl(
requestUrl: string,
renderOrigin: string
) {
const url = new URL(requestUrl);
if (["about:", "blob:", "data:"].includes(url.protocol)) {
return true;
}
return url.origin === renderOrigin;
}
export class PlaywrightPdfGenerator implements PdfGenerator {
private readonly renderOrigin: string;
private readonly renderUrl: string;
private readonly timeoutMs: number;
private readonly gate: ConcurrencyGate;
private readonly launchBrowser: () => Promise<Browser>;
private browserPromise: Promise<Browser> | undefined;
private closed = false;
constructor(options: PdfEngineOptions = {}) {
this.renderOrigin = normalizeRenderOrigin(
options.renderOrigin ??
process.env.PDF_RENDER_ORIGIN ??
"http://localhost:5173"
);
this.renderUrl = new URL(
"/preview-frame.html?target=pdf",
this.renderOrigin
).href;
this.timeoutMs = options.timeoutMs ?? 60_000;
this.gate = new ConcurrencyGate(
options.concurrency ?? 2,
options.maxQueue ?? 8
);
this.launchBrowser =
options.launchBrowser ??
(() =>
chromium.launch({
headless: true
}));
}
generate(payload: PdfRenderPayload) {
if (this.closed) {
return Promise.reject(new PdfEngineClosedError());
}
const queuedAt = performance.now();
return this.gate.run(() =>
this.generateWithBrowser(
payload,
queuedAt,
performance.now() - queuedAt
)
);
}
async warmup() {
if (this.closed) {
throw new PdfEngineClosedError();
}
await this.getBrowser();
}
async close() {
this.closed = true;
const browserPromise = this.browserPromise;
this.browserPromise = undefined;
if (!browserPromise) {
return;
}
try {
const browser = await browserPromise;
await browser.close();
} catch {
// 启动失败时没有可关闭的浏览器。
}
}
private getBrowser() {
if (!this.browserPromise) {
const browserPromise = this.launchBrowser();
this.browserPromise = browserPromise;
void browserPromise
.then((browser) => {
browser.on("disconnected", () => {
if (this.browserPromise === browserPromise) {
this.browserPromise = undefined;
}
});
})
.catch(() => {
if (this.browserPromise === browserPromise) {
this.browserPromise = undefined;
}
});
}
return this.browserPromise;
}
private async generateWithBrowser(
payload: PdfRenderPayload,
queuedAt: number,
queueMs: number
): Promise<PdfGenerationResult> {
const browserStartedAt = performance.now();
const browser = await this.getBrowser();
const browserMs = performance.now() - browserStartedAt;
const contextStartedAt = performance.now();
const context = await browser.newContext({
locale: payload.metadata.language || "zh-CN",
serviceWorkers: "block"
});
const contextMs = performance.now() - contextStartedAt;
let timeout: NodeJS.Timeout | undefined;
try {
await context.route("**/*", async (route) => {
if (
isAllowedPdfRequestUrl(
route.request().url(),
this.renderOrigin
)
) {
await route.continue();
return;
}
await route.abort("blockedbyclient");
});
const operation = this.renderWithContext(context, payload);
const timeoutPromise = new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
reject(new PdfRenderTimeoutError(this.timeoutMs));
}, this.timeoutMs);
});
const generated = await Promise.race([
operation,
timeoutPromise
]);
return {
...generated,
timings: {
queueMs,
browserMs,
contextMs,
...generated.timings,
totalMs: performance.now() - queuedAt
}
};
} finally {
if (timeout) {
clearTimeout(timeout);
}
await context.close().catch(() => undefined);
}
}
private async renderWithContext(
context: Awaited<ReturnType<Browser["newContext"]>>,
payload: PdfRenderPayload
): Promise<PdfDocumentResult> {
const navigationStartedAt = performance.now();
const page = await context.newPage();
page.setDefaultTimeout(this.timeoutMs);
page.setDefaultNavigationTimeout(this.timeoutMs);
await page.emulateMedia({ media: "print" });
await page.goto(this.renderUrl, {
waitUntil: "domcontentloaded",
timeout: this.timeoutMs
});
await page.waitForFunction(
() =>
document.documentElement.dataset.runtimeReady === "true",
undefined,
{ timeout: this.timeoutMs }
);
const navigationMs = performance.now() - navigationStartedAt;
const documentRenderStartedAt = performance.now();
const renderResult = await page.evaluate(
async (renderPayload): Promise<PagedRuntimeResult> => {
const renderer = (
window as typeof window & {
__mdToPdfRender?: (
payload: PdfRenderPayload,
target: "pdf"
) => Promise<PagedRuntimeResult>;
}
).__mdToPdfRender;
if (!renderer) {
throw new Error("PDF 分页运行时不可用");
}
return renderer(renderPayload, "pdf");
},
payload
);
const documentRenderMs =
performance.now() - documentRenderStartedAt;
const pdfPrintStartedAt = performance.now();
const pdf = await page.pdf({
displayHeaderFooter: false,
margin: {
top: "0",
right: "0",
bottom: "0",
left: "0"
},
pageRanges: `1-${Math.max(renderResult.pageCount, 1)}`,
preferCSSPageSize: true,
printBackground: payload.exportConfig.print.background,
scale: payload.exportConfig.print.scale
});
const pdfPrintMs = performance.now() - pdfPrintStartedAt;
return {
pdf,
pageCount: renderResult.pageCount,
mermaidErrors: renderResult.mermaidErrors,
timings: {
navigationMs,
documentRenderMs,
runtimeSetupMs: renderResult.timings.setupMs,
mermaidMs: renderResult.timings.mermaidMs,
mermaidFitMs: renderResult.timings.mermaidFitMs,
mermaidConversionMs:
renderResult.timings.mermaidConversionMs,
resourceWaitMs: renderResult.timings.resourceWaitMs,
paginationMs: renderResult.timings.paginationMs,
finalizeMs: renderResult.timings.finalizeMs,
runtimeTotalMs: renderResult.timings.totalMs,
pdfPrintMs
}
};
}
}
export function createPdfGenerator(options: PdfEngineOptions = {}) {
return new PlaywrightPdfGenerator(options);
}