feat: 实现 Chromium PDF 导出与精确预览
This commit is contained in:
+268
-24
@@ -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<ReturnType<PdfGenerator["generate"]>>,
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<PdfGenerationResult>;
|
||||
warmup?(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PdfEngineOptions {
|
||||
renderOrigin?: string;
|
||||
concurrency?: number;
|
||||
maxQueue?: number;
|
||||
timeoutMs?: number;
|
||||
launchBrowser?: () => Promise<Browser>;
|
||||
}
|
||||
|
||||
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<PdfGenerationResult, "timings"> {
|
||||
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<T>(task: () => Promise<T>) {
|
||||
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<void>((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<Browser>;
|
||||
private browserPromise: Promise<Browser> | 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<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;
|
||||
|
||||
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<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
|
||||
}
|
||||
};
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
await context.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
private async renderWithContext(
|
||||
context: Awaited<ReturnType<Browser["newContext"]>>,
|
||||
payload: PdfRenderPayload
|
||||
): Promise<PdfDocumentResult> {
|
||||
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<PagedRuntimeResult> => {
|
||||
const renderer = (
|
||||
window as typeof window & {
|
||||
__mdToPdfRender?: (
|
||||
payload: PdfRenderPayload,
|
||||
target: "pdf"
|
||||
) => Promise<PagedRuntimeResult>;
|
||||
}
|
||||
).__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);
|
||||
}
|
||||
Reference in New Issue
Block a user