diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index ea1756d..0032303 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -8,7 +8,11 @@ import { exportConfigSchema, supportedPaperFormats } from "@md-to-pdf/core"; -import { RENDERER_VERSION, renderMarkdown } from "@md-to-pdf/renderer"; +import { + MarkdownDocumentParseError, + RENDERER_VERSION, + renderMarkdown +} from "@md-to-pdf/renderer"; import { createPdfGenerator, PdfEngineClosedError, @@ -66,6 +70,29 @@ function validateMarkdownRequest( }; } +function renderMarkdownRequest( + markdown: string, + language: string | undefined +) { + try { + return { + success: true as const, + document: renderMarkdown(markdown, { + ...(language ? { language } : {}) + }) + }; + } catch (error) { + if (error instanceof MarkdownDocumentParseError) { + return { + success: false as const, + error: error.code, + message: error.message + }; + } + throw error; + } +} + function encodeRfc5987(value: string) { return encodeURIComponent(value).replace( /[!'()*]/g, @@ -142,7 +169,8 @@ export function buildApp(options: BuildAppOptions = {}) { const themes = createThemeRegistry({ bundledRoot: resolve(projectRoot, "themes"), localRoot: - options.localThemeRoot ?? resolve(projectRoot, ".local", "themes") + options.localThemeRoot ?? resolve(projectRoot, ".local", "themes"), + onWarning: (message) => app.log.warn(message) }); const pdfGenerator = options.pdfGenerator ?? createPdfGenerator(); @@ -254,9 +282,17 @@ export function buildApp(options: BuildAppOptions = {}) { }); } - return renderMarkdown(validated.markdown, { - ...(validated.language ? { language: validated.language } : {}) - }); + const rendered = renderMarkdownRequest( + validated.markdown, + validated.language + ); + if (!rendered.success) { + return reply.code(400).send({ + error: rendered.error, + message: rendered.message + }); + } + return rendered.document; } ); @@ -304,10 +340,18 @@ export function buildApp(options: BuildAppOptions = {}) { } const markdownStartedAt = performance.now(); - const rendered = renderMarkdown(validated.markdown, { - ...(validated.language ? { language: validated.language } : {}) - }); + const renderedResult = renderMarkdownRequest( + validated.markdown, + validated.language + ); const markdownMs = performance.now() - markdownStartedAt; + if (!renderedResult.success) { + return reply.code(400).send({ + error: renderedResult.error, + message: renderedResult.message + }); + } + const rendered = renderedResult.document; try { const generated = await pdfGenerator.generate({ diff --git a/apps/server/src/pdf-engine.ts b/apps/server/src/pdf-engine.ts index 35871c8..1cc1eee 100644 --- a/apps/server/src/pdf-engine.ts +++ b/apps/server/src/pdf-engine.ts @@ -232,6 +232,46 @@ export function isAllowedPdfRequestUrl( return url.origin === renderOrigin; } +async function waitWithPdfTimeout( + operation: Promise, + timeoutMs: number +) { + let timeout: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + operation, + new Promise((_, reject) => { + timeout = setTimeout(() => { + reject(new PdfRenderTimeoutError(timeoutMs)); + }, timeoutMs); + }) + ]); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +} + +async function closeContextWithin( + context: Awaited>, + timeoutMs: number +) { + let timeout: NodeJS.Timeout | undefined; + try { + await Promise.race([ + context.close().catch(() => undefined), + new Promise((resolve) => { + timeout = setTimeout(resolve, Math.min(timeoutMs, 5_000)); + }) + ]); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +} + export class PlaywrightPdfGenerator implements PdfGenerator { private readonly renderOrigin: string; private readonly renderUrl: string; @@ -284,7 +324,7 @@ export class PlaywrightPdfGenerator implements PdfGenerator { if (this.closed) { throw new PdfEngineClosedError(); } - await this.getBrowser(); + await waitWithPdfTimeout(this.getBrowser(), this.timeoutMs); } async close() { @@ -330,57 +370,67 @@ export class PlaywrightPdfGenerator implements PdfGenerator { 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; + let context: + | Awaited> + | undefined; + let expired = false; try { - await context.route("**/*", async (route) => { - if ( - isAllowedPdfRequestUrl( - route.request().url(), - this.renderOrigin - ) - ) { - await route.continue(); - return; + const operation = (async (): Promise => { + const browserStartedAt = performance.now(); + const browser = await this.getBrowser(); + const browserMs = performance.now() - browserStartedAt; + if (expired) { + throw new PdfRenderTimeoutError(this.timeoutMs); } - 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 + const contextStartedAt = performance.now(); + const createdContext = await browser.newContext({ + locale: payload.metadata.language || "zh-CN", + serviceWorkers: "block" + }); + context = createdContext; + const contextMs = performance.now() - contextStartedAt; + if (expired) { + await closeContextWithin(createdContext, this.timeoutMs); + throw new PdfRenderTimeoutError(this.timeoutMs); } - }; + + await createdContext.route("**/*", async (route) => { + if ( + isAllowedPdfRequestUrl( + route.request().url(), + this.renderOrigin + ) + ) { + await route.continue(); + return; + } + await route.abort("blockedbyclient"); + }); + + const generated = await this.renderWithContext( + createdContext, + payload + ); + return { + ...generated, + timings: { + queueMs, + browserMs, + contextMs, + ...generated.timings, + totalMs: performance.now() - queuedAt + } + }; + })(); + + return await waitWithPdfTimeout(operation, this.timeoutMs); } finally { - if (timeout) { - clearTimeout(timeout); + expired = true; + if (context) { + await closeContextWithin(context, this.timeoutMs); } - await context.close().catch(() => undefined); } } diff --git a/apps/server/src/theme-registry.ts b/apps/server/src/theme-registry.ts index 2394f5f..6ebb5ff 100644 --- a/apps/server/src/theme-registry.ts +++ b/apps/server/src/theme-registry.ts @@ -26,6 +26,7 @@ export interface ThemeRecord { export interface ThemeRegistryOptions { bundledRoot: string; localRoot: string; + onWarning?: (message: string) => void; } const assetContentTypes: Record = { @@ -92,7 +93,8 @@ async function resolveThemeFile(directory: string, relativePath: string) { async function readThemeRoot( root: string, - source: ThemeRecord["source"] + source: ThemeRecord["source"], + onWarning?: (message: string) => void ): Promise { let entries; try { @@ -139,6 +141,10 @@ async function readThemeRoot( }); } catch (error) { const message = error instanceof Error ? error.message : String(error); + if (source === "local") { + onWarning?.(`已忽略无效本地主题 ${entry.name}:${message}`); + continue; + } throw new Error(`主题 ${entry.name} 无效:${message}`); } } @@ -272,7 +278,7 @@ export function createThemeRegistry(options: ThemeRegistryOptions) { async function list() { const [bundledThemes, localThemes] = await Promise.all([ readThemeRoot(options.bundledRoot, "bundled"), - readThemeRoot(options.localRoot, "local") + readThemeRoot(options.localRoot, "local", options.onWarning) ]); const themes = [...bundledThemes, ...localThemes]; diff --git a/apps/server/tests/app.test.ts b/apps/server/tests/app.test.ts index 5461e2d..08d17af 100644 --- a/apps/server/tests/app.test.ts +++ b/apps/server/tests/app.test.ts @@ -110,6 +110,20 @@ describe("预览 API", () => { expect(response.json().error).toBe("INVALID_MARKDOWN"); }); + it("将无效 Front Matter 映射为文档请求错误", async () => { + app = buildApp({ logger: false }); + const response = await app.inject({ + method: "POST", + url: "/api/render", + payload: { + markdown: "---\ntitle: [未闭合\n---\n\n# 文档" + } + }); + + expect(response.statusCode).toBe(400); + expect(response.json().error).toBe("INVALID_FRONT_MATTER"); + }); + it("返回内置主题 CSS", async () => { app = buildApp(); const response = await app.inject({ @@ -237,6 +251,42 @@ describe("预览 API", () => { ); }); + it("忽略无效本地主题而不影响其他主题", async () => { + temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-theme-")); + const bundledRoot = join(temporaryDirectory, "bundled"); + const localRoot = join(temporaryDirectory, "local"); + const validThemeRoot = join(localRoot, "valid-theme"); + const invalidThemeRoot = join(localRoot, "invalid-theme"); + await mkdir(bundledRoot, { recursive: true }); + await mkdir(validThemeRoot, { recursive: true }); + await mkdir(invalidThemeRoot, { recursive: true }); + await writeFile( + join(validThemeRoot, "theme.json"), + JSON.stringify(localThemeManifest("valid-theme")), + "utf8" + ); + await writeFile( + join(validThemeRoot, "theme.css"), + "#write { color: #333; }", + "utf8" + ); + const onWarning = vi.fn(); + const registry = createThemeRegistry({ + bundledRoot, + localRoot, + onWarning + }); + + await expect(registry.list()).resolves.toEqual([ + expect.objectContaining({ + manifest: expect.objectContaining({ id: "valid-theme" }) + }) + ]); + expect(onWarning).toHaveBeenCalledWith( + expect.stringContaining("invalid-theme") + ); + }); + it("拒绝主题 CSS 导入外部地址", async () => { temporaryDirectory = await mkdtemp(join(tmpdir(), "md-to-pdf-theme-")); const bundledRoot = join(temporaryDirectory, "bundled"); @@ -357,6 +407,29 @@ describe("PDF API", () => { expect(response.json().error).toBe("INVALID_EXPORT_CONFIG"); }); + it("在 PDF 生成前拒绝无效 Front Matter", async () => { + const generate = vi.fn(); + app = buildApp({ + logger: false, + pdfGenerator: { + generate, + close: vi.fn(async () => undefined) + } + }); + const response = await app.inject({ + method: "POST", + url: "/api/pdf", + payload: { + markdown: "---\ntitle: [未闭合\n---\n", + exportConfig: defaultExportConfig + } + }); + + expect(response.statusCode).toBe(400); + expect(response.json().error).toBe("INVALID_FRONT_MATTER"); + expect(generate).not.toHaveBeenCalled(); + }); + it("映射 PDF 队列已满错误", async () => { app = buildApp({ logger: false, diff --git a/apps/server/tests/pdf-engine.test.ts b/apps/server/tests/pdf-engine.test.ts index c80235b..a9a5f2e 100644 --- a/apps/server/tests/pdf-engine.test.ts +++ b/apps/server/tests/pdf-engine.test.ts @@ -29,7 +29,10 @@ function payload(): PdfRenderPayload { }; } -function fakeBrowser(options: { hangOnGoto?: boolean } = {}) { +function fakeBrowser( + options: { hangOnGoto?: boolean; hangOnNewContext?: boolean } = {} +) { + let releaseNewContext: (() => void) | undefined; const route = vi.fn(); const closeContext = vi.fn(async () => undefined); const closeBrowser = vi.fn(async () => undefined); @@ -65,7 +68,14 @@ function fakeBrowser(options: { hangOnGoto?: boolean } = {}) { close: closeContext }; const browser = { - newContext: vi.fn(async () => context), + newContext: options.hangOnNewContext + ? vi.fn( + () => + new Promise((resolve) => { + releaseNewContext = () => resolve(context); + }) + ) + : vi.fn(async () => context), close: closeBrowser, on: vi.fn() } as unknown as Browser; @@ -77,6 +87,9 @@ function fakeBrowser(options: { hangOnGoto?: boolean } = {}) { context, page, pdfBuffer, + releaseNewContext() { + releaseNewContext?.(); + }, route }; } @@ -240,4 +253,39 @@ describe("Playwright PDF 生成器", () => { expect(fake.closeContext).toHaveBeenCalledTimes(1); await generator.close(); }); + + it("浏览器启动阶段也受生成超时限制", async () => { + const fake = fakeBrowser(); + let releaseBrowser: (() => void) | undefined; + const generator = new PlaywrightPdfGenerator({ + launchBrowser: () => + new Promise((resolve) => { + releaseBrowser = () => resolve(fake.browser); + }), + timeoutMs: 10 + }); + + await expect(generator.generate(payload())).rejects.toBeInstanceOf( + PdfRenderTimeoutError + ); + releaseBrowser?.(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await generator.close(); + }); + + it("上下文创建阶段也受生成超时限制并清理迟到上下文", async () => { + const fake = fakeBrowser({ hangOnNewContext: true }); + const generator = new PlaywrightPdfGenerator({ + launchBrowser: async () => fake.browser, + timeoutMs: 10 + }); + + await expect(generator.generate(payload())).rejects.toBeInstanceOf( + PdfRenderTimeoutError + ); + fake.releaseNewContext(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(fake.closeContext).toHaveBeenCalledTimes(1); + await generator.close(); + }); }); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index cceccdb..f203727 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -608,6 +608,9 @@ export function App() { } function handlePreviewModeChange(mode: PreviewMode) { + if (mode === previewMode) { + return; + } setPreviewMode(mode); setPreviewFrameReady(false); setPreviewPagination({ current: 0, total: 0 }); diff --git a/apps/web/src/ExportSettingsDrawer.tsx b/apps/web/src/ExportSettingsDrawer.tsx index cda91da..11e9a0e 100644 --- a/apps/web/src/ExportSettingsDrawer.tsx +++ b/apps/web/src/ExportSettingsDrawer.tsx @@ -1,5 +1,6 @@ import { getPaperDimensionsMm, + lengthToMillimeters, paperFormatLabels, supportedMermaidLayouts, supportedMermaidLooks, @@ -76,10 +77,6 @@ const mermaidFontFamilies = [ "Consolas, Microsoft YaHei, monospace" ] as const; -function millimeters(value: string) { - return Number.parseFloat(value.replace(/mm$/i, "")); -} - function fitMarginPair( first: number, second: number, @@ -103,16 +100,18 @@ function LengthInput({ maximum, onChange }: LengthInputProps) { - const [draft, setDraft] = useState(String(millimeters(value))); + const [draft, setDraft] = useState( + String(lengthToMillimeters(value)) + ); useEffect(() => { - setDraft(String(millimeters(value))); + setDraft(String(lengthToMillimeters(value))); }, [value]); function commit() { const number = Number.parseFloat(draft); if (!Number.isFinite(number)) { - setDraft(String(millimeters(value))); + setDraft(String(lengthToMillimeters(value))); return; } const normalized = Math.min(Math.max(number, 0), maximum); @@ -231,13 +230,13 @@ export function ExportSettingsDrawer({ nextPaper.orientation ); const [left, right] = fitMarginPair( - millimeters(nextPaper.margins.left), - millimeters(nextPaper.margins.right), + lengthToMillimeters(nextPaper.margins.left), + lengthToMillimeters(nextPaper.margins.right), nextDimensions.width - 1 ); const [top, bottom] = fitMarginPair( - millimeters(nextPaper.margins.top), - millimeters(nextPaper.margins.bottom), + lengthToMillimeters(nextPaper.margins.top), + lengthToMillimeters(nextPaper.margins.bottom), nextDimensions.height - 1 ); @@ -380,7 +379,9 @@ export function ExportSettingsDrawer({ label="上" value={margins.top} maximum={ - dimensions.height - millimeters(margins.bottom) - verticalGap + dimensions.height - + lengthToMillimeters(margins.bottom) - + verticalGap } onChange={(value) => updateMargin("top", value)} /> @@ -388,7 +389,9 @@ export function ExportSettingsDrawer({ label="右" value={margins.right} maximum={ - dimensions.width - millimeters(margins.left) - horizontalGap + dimensions.width - + lengthToMillimeters(margins.left) - + horizontalGap } onChange={(value) => updateMargin("right", value)} /> @@ -396,7 +399,9 @@ export function ExportSettingsDrawer({ label="下" value={margins.bottom} maximum={ - dimensions.height - millimeters(margins.top) - verticalGap + dimensions.height - + lengthToMillimeters(margins.top) - + verticalGap } onChange={(value) => updateMargin("bottom", value)} /> @@ -404,7 +409,9 @@ export function ExportSettingsDrawer({ label="左" value={margins.left} maximum={ - dimensions.width - millimeters(margins.right) - horizontalGap + dimensions.width - + lengthToMillimeters(margins.right) - + horizontalGap } onChange={(value) => updateMargin("left", value)} /> diff --git a/apps/web/src/paged-document-runtime.ts b/apps/web/src/paged-document-runtime.ts index 8e7c470..71ec3f1 100644 --- a/apps/web/src/paged-document-runtime.ts +++ b/apps/web/src/paged-document-runtime.ts @@ -2,10 +2,14 @@ import highlightCss from "highlight.js/styles/github.css?inline"; import katexCss from "katex/dist/katex.min.css?inline"; import { Previewer } from "pagedjs"; import { createMermaidSiteConfig } from "./mermaid-config"; -import { fitOversizedMermaidToPage } from "./mermaid-page-fit"; +import { + fitOversizedMermaidToPage, + getPageContentDimensions +} from "./mermaid-page-fit"; import { renderMermaidDefinitions } from "./mermaid-renderer"; import { replaceMermaidSvgWithImages } from "./mermaid-static-image"; import type { MermaidOutputMode } from "./mermaid-static-image"; +import { enablePrintMediaForPreview } from "./preview-styles"; import "./paged-table-handler"; import { buildPagedMediaCss, @@ -70,6 +74,54 @@ function stylesheet( }; } +function mountMeasurementContainer( + documentRef: Document, + content: DocumentFragment, + payload: PagedPreviewPayload, + target: PagedRenderTarget +) { + const pageContent = getPageContentDimensions(payload.exportConfig); + const style = documentRef.createElement("style"); + const themeCss = + target === "preview" + ? enablePrintMediaForPreview(payload.themeCss) + : payload.themeCss; + style.dataset.pagedMeasurementStyle = "true"; + style.textContent = [ + documentBaseCss, + highlightCss, + katexCss, + themeCss, + documentGeometryCss + ].join("\n"); + + const host = documentRef.createElement("div"); + host.dataset.pagedMeasurementHost = "true"; + host.style.cssText = [ + "position: fixed", + "left: -100000px", + "top: 0", + `width: ${pageContent.width}px`, + "height: auto", + "opacity: 0", + "pointer-events: none", + "contain: layout style paint" + ].join(";"); + + documentRef.head.append(style); + host.append(content); + documentRef.body.append(host); + + return { + host, + restore() { + content.append(...Array.from(host.childNodes)); + host.remove(); + style.remove(); + } + }; +} + function applyPageNumbers( container: ParentNode, payload: PagedPreviewPayload, @@ -233,16 +285,37 @@ export class PagedDocumentRuntime { const mermaidErrors = await this.renderMermaid(content, payload); const mermaidMs = performance.now() - mermaidStartedAt; - const mermaidFitStartedAt = performance.now(); - fitOversizedMermaidToPage(content, payload.exportConfig); - const mermaidFitMs = performance.now() - mermaidFitStartedAt; + let mermaidFitMs = 0; + let mermaidConversionMs = 0; + if (content.querySelector(".mermaid svg")) { + const measurement = mountMeasurementContainer( + documentRef, + content, + payload, + options.target + ); + try { + await documentRef.fonts.ready; + await waitForImages(measurement.host); - const mermaidConversionStartedAt = performance.now(); - if (options.mermaidOutput === "svg-image") { - replaceMermaidSvgWithImages(content); + const mermaidFitStartedAt = performance.now(); + fitOversizedMermaidToPage( + measurement.host, + payload.exportConfig + ); + mermaidFitMs = performance.now() - mermaidFitStartedAt; + + const mermaidConversionStartedAt = performance.now(); + if (options.mermaidOutput === "svg-image") { + replaceMermaidSvgWithImages(measurement.host); + await waitForImages(measurement.host); + } + mermaidConversionMs = + performance.now() - mermaidConversionStartedAt; + } finally { + measurement.restore(); + } } - const mermaidConversionMs = - performance.now() - mermaidConversionStartedAt; if (!shouldContinue()) { return undefined; diff --git a/packages/renderer/src/render-markdown.ts b/packages/renderer/src/render-markdown.ts index a0bbbe4..53a6b55 100644 --- a/packages/renderer/src/render-markdown.ts +++ b/packages/renderer/src/render-markdown.ts @@ -13,6 +13,15 @@ import sanitizeHtml from "sanitize-html"; export const RENDERER_VERSION = 1; +export class MarkdownDocumentParseError extends Error { + readonly code = "INVALID_FRONT_MATTER"; + + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "MarkdownDocumentParseError"; + } +} + export interface MarkdownDocumentMetadata { title: string; author: string; @@ -198,9 +207,10 @@ function parseFrontMatter(source: string) { return matter(source); } catch (error) { const message = error instanceof Error ? error.message : "未知错误"; - throw new Error(`无法解析 Markdown Front Matter:${message}`, { - cause: error - }); + throw new MarkdownDocumentParseError( + `无法解析 Markdown Front Matter:${message}`, + { cause: error } + ); } } diff --git a/packages/renderer/tests/render-markdown.test.ts b/packages/renderer/tests/render-markdown.test.ts index 6cd15b1..a131062 100644 --- a/packages/renderer/tests/render-markdown.test.ts +++ b/packages/renderer/tests/render-markdown.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { renderMarkdown } from "../src/render-markdown.js"; +import { + MarkdownDocumentParseError, + renderMarkdown +} from "../src/render-markdown.js"; describe("renderMarkdown", () => { it("渲染常用 Markdown 扩展并识别能力", () => { @@ -71,6 +74,17 @@ lang: zh-CN }); }); + it("使用稳定错误类型报告无效 Front Matter", () => { + expect(() => + renderMarkdown(`--- +title: [未闭合 +--- + +# 文档 +`) + ).toThrow(MarkdownDocumentParseError); + }); + it("服务端渲染公式并保留 Mermaid 安全占位", () => { const result = renderMarkdown(` 内联公式 $E=mc^2$。