diff --git a/apps/server/package.json b/apps/server/package.json index 6ff6597..b4a94bf 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -13,7 +13,8 @@ "dependencies": { "@md-to-pdf/core": "0.1.0", "@md-to-pdf/renderer": "0.1.0", - "fastify": "^5.6.2" + "fastify": "^5.6.2", + "playwright": "1.62.0" }, "devDependencies": { "@types/node": "^24.10.1", diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 6b45717..ea1756d 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -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>, + 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; } diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 081c9dc..17f6af7 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -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 { diff --git a/apps/server/src/pdf-engine.ts b/apps/server/src/pdf-engine.ts new file mode 100644 index 0000000..b95578a --- /dev/null +++ b/apps/server/src/pdf-engine.ts @@ -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; + warmup?(): Promise; + close(): Promise; +} + +export interface PdfEngineOptions { + renderOrigin?: string; + concurrency?: number; + maxQueue?: number; + timeoutMs?: number; + launchBrowser?: () => Promise; +} + +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 { + 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(task: () => Promise) { + 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((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; + private browserPromise: Promise | 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 { + 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((_, 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>, + payload: PdfRenderPayload + ): Promise { + 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 => { + const renderer = ( + window as typeof window & { + __mdToPdfRender?: ( + payload: PdfRenderPayload, + target: "pdf" + ) => Promise; + } + ).__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); +} diff --git a/apps/server/tests/app.test.ts b/apps/server/tests/app.test.ts index f11deb0..5461e2d 100644 --- a/apps/server/tests/app.test.ts +++ b/apps/server/tests/app.test.ts @@ -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" + ); + }); +}); diff --git a/apps/server/tests/pdf-engine.test.ts b/apps/server/tests/pdf-engine.test.ts new file mode 100644 index 0000000..e48bd47 --- /dev/null +++ b/apps/server/tests/pdf-engine.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Browser } from "playwright"; +import { defaultExportConfig } from "@md-to-pdf/core"; +import { + ConcurrencyGate, + PdfEngineClosedError, + PdfEngineOverloadedError, + PdfRenderTimeoutError, + PlaywrightPdfGenerator, + isAllowedPdfRequestUrl, + type PdfRenderPayload +} from "../src/pdf-engine.js"; + +function payload(): PdfRenderPayload { + return { + articleHtml: '

测试

', + fileName: "测试.md", + metadata: { + title: "测试", + author: "", + subject: "", + keywords: [], + language: "zh-CN" + }, + features: [], + themeCss: "#write { color: #333; }", + exportConfig: defaultExportConfig + }; +} + +function fakeBrowser(options: { hangOnGoto?: boolean } = {}) { + const route = vi.fn(); + const closeContext = vi.fn(async () => undefined); + const closeBrowser = vi.fn(async () => undefined); + const pdfBuffer = Buffer.from("%PDF-test"); + const page = { + setDefaultTimeout: vi.fn(), + setDefaultNavigationTimeout: vi.fn(), + emulateMedia: vi.fn(async () => undefined), + goto: options.hangOnGoto + ? vi.fn(() => new Promise(() => undefined)) + : vi.fn(async () => undefined), + waitForFunction: vi.fn(async () => undefined), + evaluate: vi.fn(async () => ({ + pageCount: 2, + contentHeight: 2200, + mermaidErrors: [], + timings: { + setupMs: 1, + mermaidMs: 2, + mermaidFitMs: 3, + mermaidConversionMs: 3.5, + resourceWaitMs: 4, + paginationMs: 5, + finalizeMs: 6, + totalMs: 21 + } + })), + pdf: vi.fn(async () => pdfBuffer) + }; + const context = { + route, + newPage: vi.fn(async () => page), + close: closeContext + }; + const browser = { + newContext: vi.fn(async () => context), + close: closeBrowser, + on: vi.fn() + } as unknown as Browser; + + return { + browser, + closeBrowser, + closeContext, + context, + page, + pdfBuffer, + route + }; +} + +describe("PDF 并发控制", () => { + it("限制并发并拒绝超过排队上限的任务", async () => { + const gate = new ConcurrencyGate(1, 1); + let releaseFirst: (() => void) | undefined; + const first = gate.run( + () => + new Promise((resolve) => { + releaseFirst = resolve; + }) + ); + const second = gate.run(async () => undefined); + + await expect( + gate.run(async () => undefined) + ).rejects.toBeInstanceOf(PdfEngineOverloadedError); + + releaseFirst?.(); + await first; + await second; + }); +}); + +describe("PDF 网络策略", () => { + it("只允许渲染同源和内嵌资源", () => { + const origin = "http://127.0.0.1:5173"; + expect( + isAllowedPdfRequestUrl( + "http://127.0.0.1:5173/api/themes/a.css", + origin + ) + ).toBe(true); + expect(isAllowedPdfRequestUrl("data:image/png;base64,AA", origin)) + .toBe(true); + expect(isAllowedPdfRequestUrl("blob:http://127.0.0.1/id", origin)) + .toBe(true); + expect( + isAllowedPdfRequestUrl("https://example.com/font.woff2", origin) + ).toBe(false); + }); +}); + +describe("Playwright PDF 生成器", () => { + it("复用浏览器、隔离上下文并返回 PDF", async () => { + const fake = fakeBrowser(); + const generator = new PlaywrightPdfGenerator({ + launchBrowser: async () => fake.browser, + timeoutMs: 1_000 + }); + + const first = await generator.generate(payload()); + const second = await generator.generate(payload()); + + expect(first.pdf.toString()).toBe("%PDF-test"); + expect(first.pdf).toBe(fake.pdfBuffer); + expect(first.pageCount).toBe(2); + expect(first.timings).toEqual( + expect.objectContaining({ + queueMs: expect.any(Number), + browserMs: expect.any(Number), + contextMs: expect.any(Number), + navigationMs: expect.any(Number), + documentRenderMs: expect.any(Number), + runtimeSetupMs: 1, + mermaidMs: 2, + mermaidFitMs: 3, + mermaidConversionMs: 3.5, + resourceWaitMs: 4, + paginationMs: 5, + finalizeMs: 6, + runtimeTotalMs: 21, + pdfPrintMs: expect.any(Number), + totalMs: expect.any(Number) + }) + ); + expect(second.pdf.toString()).toBe("%PDF-test"); + expect(fake.browser.newContext).toHaveBeenCalledTimes(2); + expect(fake.closeContext).toHaveBeenCalledTimes(2); + expect(fake.page.pdf).toHaveBeenCalledWith( + expect.objectContaining({ + pageRanges: "1-2", + preferCSSPageSize: true, + printBackground: true, + scale: 1 + }) + ); + + await generator.close(); + expect(fake.closeBrowser).toHaveBeenCalledTimes(1); + await expect(generator.generate(payload())).rejects.toBeInstanceOf( + PdfEngineClosedError + ); + }); + + it("可以在首个生成请求前预热浏览器", async () => { + const fake = fakeBrowser(); + const launchBrowser = vi.fn(async () => fake.browser); + const generator = new PlaywrightPdfGenerator({ + launchBrowser, + timeoutMs: 1_000 + }); + + await generator.warmup(); + await generator.warmup(); + expect(launchBrowser).toHaveBeenCalledTimes(1); + expect(fake.browser.newContext).not.toHaveBeenCalled(); + + await generator.generate(payload()); + expect(launchBrowser).toHaveBeenCalledTimes(1); + await generator.close(); + }); + + it("超时后关闭独立上下文", async () => { + const fake = fakeBrowser({ hangOnGoto: true }); + const generator = new PlaywrightPdfGenerator({ + launchBrowser: async () => fake.browser, + timeoutMs: 10 + }); + + await expect(generator.generate(payload())).rejects.toBeInstanceOf( + PdfRenderTimeoutError + ); + expect(fake.closeContext).toHaveBeenCalledTimes(1); + await generator.close(); + }); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 451e787..ccc580d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,6 +15,7 @@ "katex": "^0.16.28", "mermaid": "^11.12.3", "pagedjs": "^0.4.3", + "pdfjs-dist": "6.1.200", "react": "^19.2.0", "react-dom": "^19.2.0" }, diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 2c6d3d2..4119874 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,6 +1,9 @@ import { type CSSProperties, type ChangeEvent, + lazy, + Suspense, + useCallback, useEffect, useMemo, useRef, @@ -25,8 +28,23 @@ import { getNearestPageNumber, PREVIEW_SCROLLBAR_WIDTH_PX } from "./preview-page"; +import { + createPdfExportCacheKey, + downloadPdf, + getCachedPdfExport, + getOrRequestPdfExport, + type CachedPdfExport, + type PdfExportRequest +} from "./pdf-export"; import { getSyncedScrollTop } from "./scroll-sync"; +const PrecisePdfPreview = lazy(async () => { + const module = await import("./PrecisePdfPreview"); + return { default: module.PrecisePdfPreview }; +}); + +type PreviewMode = "quick" | "precise"; + interface RenderedMarkdown { articleHtml: string; metadata: { @@ -100,6 +118,11 @@ export function App() { const [themeError, setThemeError] = useState(""); const [mermaidError, setMermaidError] = useState(""); const [paginationError, setPaginationError] = useState(""); + const [pdfError, setPdfError] = useState(""); + const [exportingPdf, setExportingPdf] = useState(false); + const [previewMode, setPreviewMode] = + useState("quick"); + const [pdfCache, setPdfCache] = useState(); const [previewFrameReady, setPreviewFrameReady] = useState(false); const [previewPagination, setPreviewPagination] = useState({ current: 0, @@ -109,13 +132,19 @@ export function App() { const previewScrollRef = useRef(null); const previewFrameRef = useRef(null); const previewRequestIdRef = useRef(0); + const pdfRequestKeyRef = useRef(""); + const previewModeRef = useRef("quick"); const scrollSyncFrameRef = useRef(undefined); const scrollSyncOriginRef = useRef< "editor" | "preview" | undefined >(undefined); const error = - renderError || themeError || mermaidError || paginationError; + renderError || + themeError || + mermaidError || + paginationError || + pdfError; const themeId = exportConfig.themeId; const selectedTheme = themes.find((theme) => theme.id === themeId); const paperDimensions = getPaperDimensionsMm( @@ -140,6 +169,22 @@ export function App() { : undefined, [exportConfig, fileName, result, themeCss] ); + const pdfRequest = useMemo( + () => ({ + markdown, + fileName, + language: "zh-CN", + exportConfig + }), + [exportConfig, fileName, markdown] + ); + const pdfCacheKey = useMemo( + () => createPdfExportCacheKey(pdfRequest), + [pdfRequest] + ); + const cachedPdf = getCachedPdfExport(pdfCache, pdfCacheKey); + previewModeRef.current = previewMode; + pdfRequestKeyRef.current = pdfCacheKey; useEffect(() => { const controller = new AbortController(); @@ -191,6 +236,13 @@ export function App() { } }, [exportConfig]); + useEffect(() => { + if (pdfCache && pdfCache.key !== pdfCacheKey) { + setPdfCache(undefined); + setPreviewPagination({ current: 0, total: 0 }); + } + }, [pdfCache, pdfCacheKey]); + useEffect(() => { const controller = new AbortController(); setPreviewFrameReady(false); @@ -361,6 +413,9 @@ export function App() { }, [previewFrameReady, previewPayload]); function getPreviewScroller() { + if (previewModeRef.current === "precise") { + return previewScrollRef.current; + } return previewFrameRef.current?.contentDocument ?.scrollingElement as HTMLElement | null; } @@ -392,6 +447,10 @@ export function App() { } function updatePreviewCurrentPage() { + if (previewModeRef.current === "precise") { + updatePrecisePreviewCurrentPage(); + return; + } const frame = previewFrameRef.current; const frameDocument = frame?.contentDocument; const scrollElement = getPreviewScroller(); @@ -423,6 +482,42 @@ export function App() { ); } + function updatePrecisePreviewCurrentPage() { + const scrollElement = previewScrollRef.current; + if (!scrollElement) { + return; + } + const viewportCenter = + scrollElement.scrollTop + scrollElement.clientHeight / 2; + const pages = Array.from( + scrollElement.querySelectorAll( + ".precise-pdf-page" + ) + ).map((page) => ({ + top: page.offsetTop, + bottom: page.offsetTop + page.offsetHeight + })); + const current = getNearestPageNumber(viewportCenter, pages); + + setPreviewPagination((pagination) => + pagination.current === current && + pagination.total === pages.length + ? pagination + : { + current, + total: pages.length + } + ); + } + + const handlePrecisePageCount = useCallback((pageCount: number) => { + setPreviewPagination({ + current: pageCount > 0 ? 1 : 0, + total: pageCount + }); + window.requestAnimationFrame(updatePrecisePreviewCurrentPage); + }, []); + async function handleFileChange(event: ChangeEvent) { const file = event.target.files?.[0]; if (!file) { @@ -439,6 +534,70 @@ export function App() { } } + async function generatePdf(download: boolean) { + if (exportingPdf) { + return; + } + + setExportingPdf(true); + setPdfError(""); + setStatus( + download ? "正在生成 PDF…" : "正在生成精确预览…" + ); + const requestedKey = pdfCacheKey; + try { + const resolved = await getOrRequestPdfExport( + pdfRequest, + pdfCache + ); + if (pdfRequestKeyRef.current !== requestedKey) { + setStatus("文档已更新,已丢弃过期 PDF"); + return; + } + setPdfCache(resolved); + const exported = resolved.result; + if (download) { + downloadPdf(exported.blob, exported.fileName); + } + const pageDescription = + exported.pageCount === undefined + ? "" + : `(${exported.pageCount} 页)`; + const mermaidDescription = + exported.mermaidErrorCount > 0 + ? `,${exported.mermaidErrorCount} 个 Mermaid 图表渲染失败` + : ""; + setStatus( + `${download ? "PDF 已导出" : "精确预览已生成"}${pageDescription}${mermaidDescription}` + ); + } catch (reason) { + setPdfError( + reason instanceof Error ? reason.message : "PDF 导出失败" + ); + setStatus(download ? "PDF 导出失败" : "精确预览失败"); + } finally { + setExportingPdf(false); + } + } + + function handlePreviewModeChange(mode: PreviewMode) { + setPreviewMode(mode); + setPreviewFrameReady(false); + setPreviewPagination({ current: 0, total: 0 }); + previewScrollRef.current?.scrollTo({ top: 0 }); + if (mode === "precise" && !cachedPdf) { + void generatePdf(false); + } + } + + function handlePreviewScroll() { + if (previewModeRef.current !== "precise") { + return; + } + synchronizeScroll("preview"); + updatePrecisePreviewCurrentPage(); + } + return (
@@ -458,8 +617,12 @@ export function App() { -
@@ -497,6 +660,33 @@ export function App() { 第 {previewPagination.current} / {previewPagination.total} 页 ) : null} + + + +