fix: 修复预览与 PDF 渲染边界问题
This commit is contained in:
+52
-8
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user