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];