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
+171 -1
View File
@@ -1,10 +1,19 @@
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { FastifyInstance } from "fastify";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { buildApp } from "../src/app.js";
import {
createPdfContentDisposition,
createPdfFileName
} from "../src/app.js";
import { createThemeRegistry } from "../src/theme-registry.js";
import {
PdfEngineOverloadedError,
type PdfGenerator
} from "../src/pdf-engine.js";
import { defaultExportConfig } from "@md-to-pdf/core";
let app: FastifyInstance | undefined;
let temporaryDirectory: string | undefined;
@@ -40,6 +49,36 @@ afterEach(async () => {
});
describe("预览 API", () => {
it("关闭应用时释放 PDF 生成器", async () => {
const close = vi.fn(async () => undefined);
const pdfGenerator = {
generate: vi.fn(),
close
} as unknown as PdfGenerator;
app = buildApp({ pdfGenerator, logger: false });
await app.close();
expect(close).toHaveBeenCalledTimes(1);
app = undefined;
});
it("按配置在应用就绪时预热 PDF 浏览器", async () => {
const warmup = vi.fn(async () => undefined);
app = buildApp({
logger: false,
prewarmPdfBrowser: true,
pdfGenerator: {
generate: vi.fn(),
warmup,
close: vi.fn(async () => undefined)
} as unknown as PdfGenerator
});
await app.ready();
expect(warmup).toHaveBeenCalledTimes(1);
});
it("渲染 Markdown 并返回安全文章结构", async () => {
app = buildApp();
const response = await app.inject({
@@ -222,3 +261,134 @@ describe("预览 API", () => {
);
});
});
describe("PDF API", () => {
it("生成 PDF 并返回安全的 UTF-8 下载文件名", async () => {
const generate = vi.fn(async () => ({
pdf: Buffer.from("%PDF-test"),
pageCount: 3,
mermaidErrors: ["图表 1"],
timings: {
queueMs: 0,
browserMs: 1,
contextMs: 2,
navigationMs: 3,
documentRenderMs: 4,
runtimeSetupMs: 0.1,
mermaidMs: 0.2,
mermaidFitMs: 0.3,
mermaidConversionMs: 0.35,
resourceWaitMs: 0.4,
paginationMs: 0.5,
finalizeMs: 0.6,
runtimeTotalMs: 2.1,
pdfPrintMs: 5,
totalMs: 15
}
}));
app = buildApp({
logger: false,
pdfGenerator: {
generate,
close: vi.fn(async () => undefined)
}
});
const response = await app.inject({
method: "POST",
url: "/api/pdf",
payload: {
markdown: "# 测试文档",
fileName: "目录/测试文档.md",
language: "zh-CN",
exportConfig: defaultExportConfig
}
});
expect(response.statusCode).toBe(200);
expect(response.headers["content-type"]).toContain(
"application/pdf"
);
expect(response.headers["content-disposition"]).toContain(
"filename*=UTF-8''%E6%B5%8B%E8%AF%95%E6%96%87%E6%A1%A3.pdf"
);
expect(response.headers["x-pdf-page-count"]).toBe("3");
expect(response.headers["x-mermaid-error-count"]).toBe("1");
expect(response.headers["server-timing"]).toContain(
"document-render;dur=4.0"
);
expect(response.headers["server-timing"]).toContain(
"pdf-print;dur=5.0"
);
expect(response.headers["server-timing"]).toContain(
"mermaid;dur=0.2"
);
expect(response.headers["server-timing"]).toContain(
"mermaid-conversion;dur=0.3"
);
expect(response.headers["server-timing"]).toContain(
"pagination;dur=0.5"
);
expect(response.rawPayload).toEqual(Buffer.from("%PDF-test"));
expect(generate).toHaveBeenCalledWith(
expect.objectContaining({
articleHtml: expect.stringContaining('id="write"'),
themeCss: expect.stringContaining("#write"),
exportConfig: defaultExportConfig
})
);
});
it("拒绝无效导出配置", async () => {
app = buildApp({ logger: false });
const response = await app.inject({
method: "POST",
url: "/api/pdf",
payload: {
markdown: "# 测试",
exportConfig: {
...defaultExportConfig,
version: 999
}
}
});
expect(response.statusCode).toBe(400);
expect(response.json().error).toBe("INVALID_EXPORT_CONFIG");
});
it("映射 PDF 队列已满错误", async () => {
app = buildApp({
logger: false,
pdfGenerator: {
generate: vi.fn(async () => {
throw new PdfEngineOverloadedError();
}),
close: vi.fn(async () => undefined)
}
});
const response = await app.inject({
method: "POST",
url: "/api/pdf",
payload: {
markdown: "# 测试",
exportConfig: defaultExportConfig
}
});
expect(response.statusCode).toBe(503);
expect(response.headers["retry-after"]).toBe("5");
expect(response.json().error).toBe("PDF_QUEUE_FULL");
});
it("规范化 PDF 文件名和下载响应头", () => {
expect(createPdfFileName("../报告.markdown")).toBe("报告.pdf");
expect(createPdfFileName("非法<>名称.md")).toBe(
"非法__名称.pdf"
);
expect(createPdfFileName(undefined)).toBe("文档.pdf");
expect(createPdfContentDisposition("报告.pdf")).toContain(
"filename*=UTF-8''%E6%8A%A5%E5%91%8A.pdf"
);
});
});