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;
}