feat: 实现 Chromium PDF 导出与精确预览
This commit is contained in:
+268
-24
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user