fix: 修复预览与 PDF 渲染边界问题

This commit is contained in:
SkyJourney
2026-07-27 00:33:56 +08:00
parent d93728bb8b
commit b459def232
10 changed files with 413 additions and 85 deletions
+52 -8
View File
@@ -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({
+95 -45
View File
@@ -232,6 +232,46 @@ export function isAllowedPdfRequestUrl(
return url.origin === renderOrigin;
}
async function waitWithPdfTimeout<T>(
operation: Promise<T>,
timeoutMs: number
) {
let timeout: NodeJS.Timeout | undefined;
try {
return await Promise.race([
operation,
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
reject(new PdfRenderTimeoutError(timeoutMs));
}, timeoutMs);
})
]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}
async function closeContextWithin(
context: Awaited<ReturnType<Browser["newContext"]>>,
timeoutMs: number
) {
let timeout: NodeJS.Timeout | undefined;
try {
await Promise.race([
context.close().catch(() => undefined),
new Promise<void>((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<PdfGenerationResult> {
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<ReturnType<Browser["newContext"]>>
| 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<PdfGenerationResult> => {
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<never>((_, 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);
}
}
+8 -2
View File
@@ -26,6 +26,7 @@ export interface ThemeRecord {
export interface ThemeRegistryOptions {
bundledRoot: string;
localRoot: string;
onWarning?: (message: string) => void;
}
const assetContentTypes: Record<string, string> = {
@@ -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<ThemeRecord[]> {
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];
+73
View File
@@ -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,
+50 -2
View File
@@ -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<typeof context>((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<Browser>((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();
});
});
+3
View File
@@ -608,6 +608,9 @@ export function App() {
}
function handlePreviewModeChange(mode: PreviewMode) {
if (mode === previewMode) {
return;
}
setPreviewMode(mode);
setPreviewFrameReady(false);
setPreviewPagination({ current: 0, total: 0 });
+22 -15
View File
@@ -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)}
/>
+82 -9
View File
@@ -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;
+13 -3
View File
@@ -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 }
);
}
}
@@ -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$