feat: 实现 Chromium PDF 导出与精确预览
This commit is contained in:
@@ -13,7 +13,8 @@
|
||||
"dependencies": {
|
||||
"@md-to-pdf/core": "0.1.0",
|
||||
"@md-to-pdf/renderer": "0.1.0",
|
||||
"fastify": "^5.6.2"
|
||||
"fastify": "^5.6.2",
|
||||
"playwright": "1.62.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
|
||||
+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);
|
||||
}
|
||||
@@ -1,10 +1,19 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { buildApp } from "../src/app.js";
|
||||
import {
|
||||
createPdfContentDisposition,
|
||||
createPdfFileName
|
||||
} from "../src/app.js";
|
||||
import { createThemeRegistry } from "../src/theme-registry.js";
|
||||
import {
|
||||
PdfEngineOverloadedError,
|
||||
type PdfGenerator
|
||||
} from "../src/pdf-engine.js";
|
||||
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||
|
||||
let app: FastifyInstance | undefined;
|
||||
let temporaryDirectory: string | undefined;
|
||||
@@ -40,6 +49,36 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("预览 API", () => {
|
||||
it("关闭应用时释放 PDF 生成器", async () => {
|
||||
const close = vi.fn(async () => undefined);
|
||||
const pdfGenerator = {
|
||||
generate: vi.fn(),
|
||||
close
|
||||
} as unknown as PdfGenerator;
|
||||
app = buildApp({ pdfGenerator, logger: false });
|
||||
|
||||
await app.close();
|
||||
|
||||
expect(close).toHaveBeenCalledTimes(1);
|
||||
app = undefined;
|
||||
});
|
||||
|
||||
it("按配置在应用就绪时预热 PDF 浏览器", async () => {
|
||||
const warmup = vi.fn(async () => undefined);
|
||||
app = buildApp({
|
||||
logger: false,
|
||||
prewarmPdfBrowser: true,
|
||||
pdfGenerator: {
|
||||
generate: vi.fn(),
|
||||
warmup,
|
||||
close: vi.fn(async () => undefined)
|
||||
} as unknown as PdfGenerator
|
||||
});
|
||||
|
||||
await app.ready();
|
||||
expect(warmup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("渲染 Markdown 并返回安全文章结构", async () => {
|
||||
app = buildApp();
|
||||
const response = await app.inject({
|
||||
@@ -222,3 +261,134 @@ describe("预览 API", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PDF API", () => {
|
||||
it("生成 PDF 并返回安全的 UTF-8 下载文件名", async () => {
|
||||
const generate = vi.fn(async () => ({
|
||||
pdf: Buffer.from("%PDF-test"),
|
||||
pageCount: 3,
|
||||
mermaidErrors: ["图表 1"],
|
||||
timings: {
|
||||
queueMs: 0,
|
||||
browserMs: 1,
|
||||
contextMs: 2,
|
||||
navigationMs: 3,
|
||||
documentRenderMs: 4,
|
||||
runtimeSetupMs: 0.1,
|
||||
mermaidMs: 0.2,
|
||||
mermaidFitMs: 0.3,
|
||||
mermaidConversionMs: 0.35,
|
||||
resourceWaitMs: 0.4,
|
||||
paginationMs: 0.5,
|
||||
finalizeMs: 0.6,
|
||||
runtimeTotalMs: 2.1,
|
||||
pdfPrintMs: 5,
|
||||
totalMs: 15
|
||||
}
|
||||
}));
|
||||
app = buildApp({
|
||||
logger: false,
|
||||
pdfGenerator: {
|
||||
generate,
|
||||
close: vi.fn(async () => undefined)
|
||||
}
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/pdf",
|
||||
payload: {
|
||||
markdown: "# 测试文档",
|
||||
fileName: "目录/测试文档.md",
|
||||
language: "zh-CN",
|
||||
exportConfig: defaultExportConfig
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers["content-type"]).toContain(
|
||||
"application/pdf"
|
||||
);
|
||||
expect(response.headers["content-disposition"]).toContain(
|
||||
"filename*=UTF-8''%E6%B5%8B%E8%AF%95%E6%96%87%E6%A1%A3.pdf"
|
||||
);
|
||||
expect(response.headers["x-pdf-page-count"]).toBe("3");
|
||||
expect(response.headers["x-mermaid-error-count"]).toBe("1");
|
||||
expect(response.headers["server-timing"]).toContain(
|
||||
"document-render;dur=4.0"
|
||||
);
|
||||
expect(response.headers["server-timing"]).toContain(
|
||||
"pdf-print;dur=5.0"
|
||||
);
|
||||
expect(response.headers["server-timing"]).toContain(
|
||||
"mermaid;dur=0.2"
|
||||
);
|
||||
expect(response.headers["server-timing"]).toContain(
|
||||
"mermaid-conversion;dur=0.3"
|
||||
);
|
||||
expect(response.headers["server-timing"]).toContain(
|
||||
"pagination;dur=0.5"
|
||||
);
|
||||
expect(response.rawPayload).toEqual(Buffer.from("%PDF-test"));
|
||||
expect(generate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
articleHtml: expect.stringContaining('id="write"'),
|
||||
themeCss: expect.stringContaining("#write"),
|
||||
exportConfig: defaultExportConfig
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("拒绝无效导出配置", async () => {
|
||||
app = buildApp({ logger: false });
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/pdf",
|
||||
payload: {
|
||||
markdown: "# 测试",
|
||||
exportConfig: {
|
||||
...defaultExportConfig,
|
||||
version: 999
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error).toBe("INVALID_EXPORT_CONFIG");
|
||||
});
|
||||
|
||||
it("映射 PDF 队列已满错误", async () => {
|
||||
app = buildApp({
|
||||
logger: false,
|
||||
pdfGenerator: {
|
||||
generate: vi.fn(async () => {
|
||||
throw new PdfEngineOverloadedError();
|
||||
}),
|
||||
close: vi.fn(async () => undefined)
|
||||
}
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/pdf",
|
||||
payload: {
|
||||
markdown: "# 测试",
|
||||
exportConfig: defaultExportConfig
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(503);
|
||||
expect(response.headers["retry-after"]).toBe("5");
|
||||
expect(response.json().error).toBe("PDF_QUEUE_FULL");
|
||||
});
|
||||
|
||||
it("规范化 PDF 文件名和下载响应头", () => {
|
||||
expect(createPdfFileName("../报告.markdown")).toBe("报告.pdf");
|
||||
expect(createPdfFileName("非法<>名称.md")).toBe(
|
||||
"非法__名称.pdf"
|
||||
);
|
||||
expect(createPdfFileName(undefined)).toBe("文档.pdf");
|
||||
expect(createPdfContentDisposition("报告.pdf")).toContain(
|
||||
"filename*=UTF-8''%E6%8A%A5%E5%91%8A.pdf"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Browser } from "playwright";
|
||||
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||
import {
|
||||
ConcurrencyGate,
|
||||
PdfEngineClosedError,
|
||||
PdfEngineOverloadedError,
|
||||
PdfRenderTimeoutError,
|
||||
PlaywrightPdfGenerator,
|
||||
isAllowedPdfRequestUrl,
|
||||
type PdfRenderPayload
|
||||
} from "../src/pdf-engine.js";
|
||||
|
||||
function payload(): PdfRenderPayload {
|
||||
return {
|
||||
articleHtml: '<article id="write"><h1>测试</h1></article>',
|
||||
fileName: "测试.md",
|
||||
metadata: {
|
||||
title: "测试",
|
||||
author: "",
|
||||
subject: "",
|
||||
keywords: [],
|
||||
language: "zh-CN"
|
||||
},
|
||||
features: [],
|
||||
themeCss: "#write { color: #333; }",
|
||||
exportConfig: defaultExportConfig
|
||||
};
|
||||
}
|
||||
|
||||
function fakeBrowser(options: { hangOnGoto?: boolean } = {}) {
|
||||
const route = vi.fn();
|
||||
const closeContext = vi.fn(async () => undefined);
|
||||
const closeBrowser = vi.fn(async () => undefined);
|
||||
const pdfBuffer = Buffer.from("%PDF-test");
|
||||
const page = {
|
||||
setDefaultTimeout: vi.fn(),
|
||||
setDefaultNavigationTimeout: vi.fn(),
|
||||
emulateMedia: vi.fn(async () => undefined),
|
||||
goto: options.hangOnGoto
|
||||
? vi.fn(() => new Promise(() => undefined))
|
||||
: vi.fn(async () => undefined),
|
||||
waitForFunction: vi.fn(async () => undefined),
|
||||
evaluate: vi.fn(async () => ({
|
||||
pageCount: 2,
|
||||
contentHeight: 2200,
|
||||
mermaidErrors: [],
|
||||
timings: {
|
||||
setupMs: 1,
|
||||
mermaidMs: 2,
|
||||
mermaidFitMs: 3,
|
||||
mermaidConversionMs: 3.5,
|
||||
resourceWaitMs: 4,
|
||||
paginationMs: 5,
|
||||
finalizeMs: 6,
|
||||
totalMs: 21
|
||||
}
|
||||
})),
|
||||
pdf: vi.fn(async () => pdfBuffer)
|
||||
};
|
||||
const context = {
|
||||
route,
|
||||
newPage: vi.fn(async () => page),
|
||||
close: closeContext
|
||||
};
|
||||
const browser = {
|
||||
newContext: vi.fn(async () => context),
|
||||
close: closeBrowser,
|
||||
on: vi.fn()
|
||||
} as unknown as Browser;
|
||||
|
||||
return {
|
||||
browser,
|
||||
closeBrowser,
|
||||
closeContext,
|
||||
context,
|
||||
page,
|
||||
pdfBuffer,
|
||||
route
|
||||
};
|
||||
}
|
||||
|
||||
describe("PDF 并发控制", () => {
|
||||
it("限制并发并拒绝超过排队上限的任务", async () => {
|
||||
const gate = new ConcurrencyGate(1, 1);
|
||||
let releaseFirst: (() => void) | undefined;
|
||||
const first = gate.run(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
})
|
||||
);
|
||||
const second = gate.run(async () => undefined);
|
||||
|
||||
await expect(
|
||||
gate.run(async () => undefined)
|
||||
).rejects.toBeInstanceOf(PdfEngineOverloadedError);
|
||||
|
||||
releaseFirst?.();
|
||||
await first;
|
||||
await second;
|
||||
});
|
||||
});
|
||||
|
||||
describe("PDF 网络策略", () => {
|
||||
it("只允许渲染同源和内嵌资源", () => {
|
||||
const origin = "http://127.0.0.1:5173";
|
||||
expect(
|
||||
isAllowedPdfRequestUrl(
|
||||
"http://127.0.0.1:5173/api/themes/a.css",
|
||||
origin
|
||||
)
|
||||
).toBe(true);
|
||||
expect(isAllowedPdfRequestUrl("data:image/png;base64,AA", origin))
|
||||
.toBe(true);
|
||||
expect(isAllowedPdfRequestUrl("blob:http://127.0.0.1/id", origin))
|
||||
.toBe(true);
|
||||
expect(
|
||||
isAllowedPdfRequestUrl("https://example.com/font.woff2", origin)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Playwright PDF 生成器", () => {
|
||||
it("复用浏览器、隔离上下文并返回 PDF", async () => {
|
||||
const fake = fakeBrowser();
|
||||
const generator = new PlaywrightPdfGenerator({
|
||||
launchBrowser: async () => fake.browser,
|
||||
timeoutMs: 1_000
|
||||
});
|
||||
|
||||
const first = await generator.generate(payload());
|
||||
const second = await generator.generate(payload());
|
||||
|
||||
expect(first.pdf.toString()).toBe("%PDF-test");
|
||||
expect(first.pdf).toBe(fake.pdfBuffer);
|
||||
expect(first.pageCount).toBe(2);
|
||||
expect(first.timings).toEqual(
|
||||
expect.objectContaining({
|
||||
queueMs: expect.any(Number),
|
||||
browserMs: expect.any(Number),
|
||||
contextMs: expect.any(Number),
|
||||
navigationMs: expect.any(Number),
|
||||
documentRenderMs: expect.any(Number),
|
||||
runtimeSetupMs: 1,
|
||||
mermaidMs: 2,
|
||||
mermaidFitMs: 3,
|
||||
mermaidConversionMs: 3.5,
|
||||
resourceWaitMs: 4,
|
||||
paginationMs: 5,
|
||||
finalizeMs: 6,
|
||||
runtimeTotalMs: 21,
|
||||
pdfPrintMs: expect.any(Number),
|
||||
totalMs: expect.any(Number)
|
||||
})
|
||||
);
|
||||
expect(second.pdf.toString()).toBe("%PDF-test");
|
||||
expect(fake.browser.newContext).toHaveBeenCalledTimes(2);
|
||||
expect(fake.closeContext).toHaveBeenCalledTimes(2);
|
||||
expect(fake.page.pdf).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
pageRanges: "1-2",
|
||||
preferCSSPageSize: true,
|
||||
printBackground: true,
|
||||
scale: 1
|
||||
})
|
||||
);
|
||||
|
||||
await generator.close();
|
||||
expect(fake.closeBrowser).toHaveBeenCalledTimes(1);
|
||||
await expect(generator.generate(payload())).rejects.toBeInstanceOf(
|
||||
PdfEngineClosedError
|
||||
);
|
||||
});
|
||||
|
||||
it("可以在首个生成请求前预热浏览器", async () => {
|
||||
const fake = fakeBrowser();
|
||||
const launchBrowser = vi.fn(async () => fake.browser);
|
||||
const generator = new PlaywrightPdfGenerator({
|
||||
launchBrowser,
|
||||
timeoutMs: 1_000
|
||||
});
|
||||
|
||||
await generator.warmup();
|
||||
await generator.warmup();
|
||||
expect(launchBrowser).toHaveBeenCalledTimes(1);
|
||||
expect(fake.browser.newContext).not.toHaveBeenCalled();
|
||||
|
||||
await generator.generate(payload());
|
||||
expect(launchBrowser).toHaveBeenCalledTimes(1);
|
||||
await generator.close();
|
||||
});
|
||||
|
||||
it("超时后关闭独立上下文", async () => {
|
||||
const fake = fakeBrowser({ hangOnGoto: true });
|
||||
const generator = new PlaywrightPdfGenerator({
|
||||
launchBrowser: async () => fake.browser,
|
||||
timeoutMs: 10
|
||||
});
|
||||
|
||||
await expect(generator.generate(payload())).rejects.toBeInstanceOf(
|
||||
PdfRenderTimeoutError
|
||||
);
|
||||
expect(fake.closeContext).toHaveBeenCalledTimes(1);
|
||||
await generator.close();
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@
|
||||
"katex": "^0.16.28",
|
||||
"mermaid": "^11.12.3",
|
||||
"pagedjs": "^0.4.3",
|
||||
"pdfjs-dist": "6.1.200",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
|
||||
+237
-15
@@ -1,6 +1,9 @@
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ChangeEvent,
|
||||
lazy,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
@@ -25,8 +28,23 @@ import {
|
||||
getNearestPageNumber,
|
||||
PREVIEW_SCROLLBAR_WIDTH_PX
|
||||
} from "./preview-page";
|
||||
import {
|
||||
createPdfExportCacheKey,
|
||||
downloadPdf,
|
||||
getCachedPdfExport,
|
||||
getOrRequestPdfExport,
|
||||
type CachedPdfExport,
|
||||
type PdfExportRequest
|
||||
} from "./pdf-export";
|
||||
import { getSyncedScrollTop } from "./scroll-sync";
|
||||
|
||||
const PrecisePdfPreview = lazy(async () => {
|
||||
const module = await import("./PrecisePdfPreview");
|
||||
return { default: module.PrecisePdfPreview };
|
||||
});
|
||||
|
||||
type PreviewMode = "quick" | "precise";
|
||||
|
||||
interface RenderedMarkdown {
|
||||
articleHtml: string;
|
||||
metadata: {
|
||||
@@ -100,6 +118,11 @@ export function App() {
|
||||
const [themeError, setThemeError] = useState("");
|
||||
const [mermaidError, setMermaidError] = useState("");
|
||||
const [paginationError, setPaginationError] = useState("");
|
||||
const [pdfError, setPdfError] = useState("");
|
||||
const [exportingPdf, setExportingPdf] = useState(false);
|
||||
const [previewMode, setPreviewMode] =
|
||||
useState<PreviewMode>("quick");
|
||||
const [pdfCache, setPdfCache] = useState<CachedPdfExport>();
|
||||
const [previewFrameReady, setPreviewFrameReady] = useState(false);
|
||||
const [previewPagination, setPreviewPagination] = useState({
|
||||
current: 0,
|
||||
@@ -109,13 +132,19 @@ export function App() {
|
||||
const previewScrollRef = useRef<HTMLDivElement>(null);
|
||||
const previewFrameRef = useRef<HTMLIFrameElement>(null);
|
||||
const previewRequestIdRef = useRef(0);
|
||||
const pdfRequestKeyRef = useRef("");
|
||||
const previewModeRef = useRef<PreviewMode>("quick");
|
||||
const scrollSyncFrameRef = useRef<number | undefined>(undefined);
|
||||
const scrollSyncOriginRef = useRef<
|
||||
"editor" | "preview" | undefined
|
||||
>(undefined);
|
||||
|
||||
const error =
|
||||
renderError || themeError || mermaidError || paginationError;
|
||||
renderError ||
|
||||
themeError ||
|
||||
mermaidError ||
|
||||
paginationError ||
|
||||
pdfError;
|
||||
const themeId = exportConfig.themeId;
|
||||
const selectedTheme = themes.find((theme) => theme.id === themeId);
|
||||
const paperDimensions = getPaperDimensionsMm(
|
||||
@@ -140,6 +169,22 @@ export function App() {
|
||||
: undefined,
|
||||
[exportConfig, fileName, result, themeCss]
|
||||
);
|
||||
const pdfRequest = useMemo<PdfExportRequest>(
|
||||
() => ({
|
||||
markdown,
|
||||
fileName,
|
||||
language: "zh-CN",
|
||||
exportConfig
|
||||
}),
|
||||
[exportConfig, fileName, markdown]
|
||||
);
|
||||
const pdfCacheKey = useMemo(
|
||||
() => createPdfExportCacheKey(pdfRequest),
|
||||
[pdfRequest]
|
||||
);
|
||||
const cachedPdf = getCachedPdfExport(pdfCache, pdfCacheKey);
|
||||
previewModeRef.current = previewMode;
|
||||
pdfRequestKeyRef.current = pdfCacheKey;
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
@@ -191,6 +236,13 @@ export function App() {
|
||||
}
|
||||
}, [exportConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pdfCache && pdfCache.key !== pdfCacheKey) {
|
||||
setPdfCache(undefined);
|
||||
setPreviewPagination({ current: 0, total: 0 });
|
||||
}
|
||||
}, [pdfCache, pdfCacheKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setPreviewFrameReady(false);
|
||||
@@ -361,6 +413,9 @@ export function App() {
|
||||
}, [previewFrameReady, previewPayload]);
|
||||
|
||||
function getPreviewScroller() {
|
||||
if (previewModeRef.current === "precise") {
|
||||
return previewScrollRef.current;
|
||||
}
|
||||
return previewFrameRef.current?.contentDocument
|
||||
?.scrollingElement as HTMLElement | null;
|
||||
}
|
||||
@@ -392,6 +447,10 @@ export function App() {
|
||||
}
|
||||
|
||||
function updatePreviewCurrentPage() {
|
||||
if (previewModeRef.current === "precise") {
|
||||
updatePrecisePreviewCurrentPage();
|
||||
return;
|
||||
}
|
||||
const frame = previewFrameRef.current;
|
||||
const frameDocument = frame?.contentDocument;
|
||||
const scrollElement = getPreviewScroller();
|
||||
@@ -423,6 +482,42 @@ export function App() {
|
||||
);
|
||||
}
|
||||
|
||||
function updatePrecisePreviewCurrentPage() {
|
||||
const scrollElement = previewScrollRef.current;
|
||||
if (!scrollElement) {
|
||||
return;
|
||||
}
|
||||
const viewportCenter =
|
||||
scrollElement.scrollTop + scrollElement.clientHeight / 2;
|
||||
const pages = Array.from(
|
||||
scrollElement.querySelectorAll<HTMLElement>(
|
||||
".precise-pdf-page"
|
||||
)
|
||||
).map((page) => ({
|
||||
top: page.offsetTop,
|
||||
bottom: page.offsetTop + page.offsetHeight
|
||||
}));
|
||||
const current = getNearestPageNumber(viewportCenter, pages);
|
||||
|
||||
setPreviewPagination((pagination) =>
|
||||
pagination.current === current &&
|
||||
pagination.total === pages.length
|
||||
? pagination
|
||||
: {
|
||||
current,
|
||||
total: pages.length
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const handlePrecisePageCount = useCallback((pageCount: number) => {
|
||||
setPreviewPagination({
|
||||
current: pageCount > 0 ? 1 : 0,
|
||||
total: pageCount
|
||||
});
|
||||
window.requestAnimationFrame(updatePrecisePreviewCurrentPage);
|
||||
}, []);
|
||||
|
||||
async function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) {
|
||||
@@ -439,6 +534,70 @@ export function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function generatePdf(download: boolean) {
|
||||
if (exportingPdf) {
|
||||
return;
|
||||
}
|
||||
|
||||
setExportingPdf(true);
|
||||
setPdfError("");
|
||||
setStatus(
|
||||
download ? "正在生成 PDF…" : "正在生成精确预览…"
|
||||
);
|
||||
const requestedKey = pdfCacheKey;
|
||||
try {
|
||||
const resolved = await getOrRequestPdfExport(
|
||||
pdfRequest,
|
||||
pdfCache
|
||||
);
|
||||
if (pdfRequestKeyRef.current !== requestedKey) {
|
||||
setStatus("文档已更新,已丢弃过期 PDF");
|
||||
return;
|
||||
}
|
||||
setPdfCache(resolved);
|
||||
const exported = resolved.result;
|
||||
if (download) {
|
||||
downloadPdf(exported.blob, exported.fileName);
|
||||
}
|
||||
const pageDescription =
|
||||
exported.pageCount === undefined
|
||||
? ""
|
||||
: `(${exported.pageCount} 页)`;
|
||||
const mermaidDescription =
|
||||
exported.mermaidErrorCount > 0
|
||||
? `,${exported.mermaidErrorCount} 个 Mermaid 图表渲染失败`
|
||||
: "";
|
||||
setStatus(
|
||||
`${download ? "PDF 已导出" : "精确预览已生成"}${pageDescription}${mermaidDescription}`
|
||||
);
|
||||
} catch (reason) {
|
||||
setPdfError(
|
||||
reason instanceof Error ? reason.message : "PDF 导出失败"
|
||||
);
|
||||
setStatus(download ? "PDF 导出失败" : "精确预览失败");
|
||||
} finally {
|
||||
setExportingPdf(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePreviewModeChange(mode: PreviewMode) {
|
||||
setPreviewMode(mode);
|
||||
setPreviewFrameReady(false);
|
||||
setPreviewPagination({ current: 0, total: 0 });
|
||||
previewScrollRef.current?.scrollTo({ top: 0 });
|
||||
if (mode === "precise" && !cachedPdf) {
|
||||
void generatePdf(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePreviewScroll() {
|
||||
if (previewModeRef.current !== "precise") {
|
||||
return;
|
||||
}
|
||||
synchronizeScroll("preview");
|
||||
updatePrecisePreviewCurrentPage();
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="workspace">
|
||||
<header className="topbar">
|
||||
@@ -458,8 +617,12 @@ export function App() {
|
||||
<button type="button" onClick={() => setSettingsOpen(true)}>
|
||||
导出设置
|
||||
</button>
|
||||
<button type="button" disabled title="下一阶段实现">
|
||||
导出 PDF
|
||||
<button
|
||||
type="button"
|
||||
disabled={exportingPdf || !markdown.trim()}
|
||||
onClick={() => void generatePdf(true)}
|
||||
>
|
||||
{exportingPdf ? "正在导出…" : "导出 PDF"}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -497,6 +660,33 @@ export function App() {
|
||||
第 {previewPagination.current} / {previewPagination.total} 页
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className="preview-mode-control"
|
||||
role="group"
|
||||
aria-label="预览模式"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
previewMode === "quick" ? "is-active" : ""
|
||||
}
|
||||
aria-pressed={previewMode === "quick"}
|
||||
onClick={() => handlePreviewModeChange("quick")}
|
||||
>
|
||||
快速
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={
|
||||
previewMode === "precise" ? "is-active" : ""
|
||||
}
|
||||
aria-pressed={previewMode === "precise"}
|
||||
disabled={exportingPdf}
|
||||
onClick={() => handlePreviewModeChange("precise")}
|
||||
>
|
||||
精确
|
||||
</button>
|
||||
</span>
|
||||
<label className="theme-control">
|
||||
<span className="sr-only">预览主题</span>
|
||||
<select
|
||||
@@ -528,20 +718,52 @@ export function App() {
|
||||
<div
|
||||
ref={previewScrollRef}
|
||||
className="preview-scroll"
|
||||
onScroll={handlePreviewScroll}
|
||||
>
|
||||
<div className="paper" style={paperStyle}>
|
||||
{previewPayload ? (
|
||||
<iframe
|
||||
ref={previewFrameRef}
|
||||
className="preview-frame"
|
||||
title="文档内容预览"
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
src="/preview-frame.html"
|
||||
{previewMode === "quick" ? (
|
||||
<div className="paper" style={paperStyle}>
|
||||
{previewPayload ? (
|
||||
<iframe
|
||||
ref={previewFrameRef}
|
||||
className="preview-frame"
|
||||
title="文档内容预览"
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
src="/preview-frame.html"
|
||||
/>
|
||||
) : (
|
||||
<div className="preview-loading">
|
||||
正在生成文档预览…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : exportingPdf && !cachedPdf ? (
|
||||
<div className="precise-pdf-status">
|
||||
正在使用固定版本 Chromium 生成精确预览…
|
||||
</div>
|
||||
) : cachedPdf ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="precise-pdf-status">
|
||||
正在加载 PDF 查看器…
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PrecisePdfPreview
|
||||
blob={cachedPdf.blob}
|
||||
onPageCount={handlePrecisePageCount}
|
||||
/>
|
||||
) : (
|
||||
<div className="preview-loading">正在生成文档预览…</div>
|
||||
)}
|
||||
</div>
|
||||
</Suspense>
|
||||
) : (
|
||||
<div className="precise-pdf-status">
|
||||
<span>当前内容尚未生成精确预览</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void generatePdf(false)}
|
||||
>
|
||||
生成精确预览
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
import {
|
||||
type CSSProperties,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from "react";
|
||||
import {
|
||||
getDocument,
|
||||
GlobalWorkerOptions,
|
||||
RenderingCancelledException,
|
||||
TextLayer,
|
||||
type PDFDocumentProxy,
|
||||
type PDFPageProxy,
|
||||
type RenderTask
|
||||
} from "pdfjs-dist";
|
||||
import pdfWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
||||
import {
|
||||
getPdfCanvasRenderScale,
|
||||
getPdfPageCssDimensions
|
||||
} from "./pdf-page-geometry";
|
||||
import { PdfPageRenderQueue } from "./pdf-render-queue";
|
||||
|
||||
GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
|
||||
|
||||
interface PdfPageDescriptor {
|
||||
pageNumber: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface PrecisePdfPreviewProps {
|
||||
blob: Blob;
|
||||
onPageCount?: (pageCount: number) => void;
|
||||
}
|
||||
|
||||
interface PrecisePdfPageProps {
|
||||
document: PDFDocumentProxy;
|
||||
descriptor: PdfPageDescriptor;
|
||||
renderQueue: PdfPageRenderQueue;
|
||||
}
|
||||
|
||||
function useNearViewport(elementRef: React.RefObject<HTMLElement | null>) {
|
||||
const [nearViewport, setNearViewport] = useState(false);
|
||||
const releaseTimerRef = useRef<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const element = elementRef.current;
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
if (!("IntersectionObserver" in window)) {
|
||||
setNearViewport(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry?.isIntersecting) {
|
||||
if (releaseTimerRef.current !== undefined) {
|
||||
window.clearTimeout(releaseTimerRef.current);
|
||||
releaseTimerRef.current = undefined;
|
||||
}
|
||||
setNearViewport(true);
|
||||
return;
|
||||
}
|
||||
|
||||
releaseTimerRef.current = window.setTimeout(() => {
|
||||
releaseTimerRef.current = undefined;
|
||||
setNearViewport(false);
|
||||
}, 1_500);
|
||||
},
|
||||
{
|
||||
rootMargin: "1200px 0px"
|
||||
}
|
||||
);
|
||||
observer.observe(element);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (releaseTimerRef.current !== undefined) {
|
||||
window.clearTimeout(releaseTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [elementRef]);
|
||||
|
||||
return nearViewport;
|
||||
}
|
||||
|
||||
function PrecisePdfPage({
|
||||
document,
|
||||
descriptor,
|
||||
renderQueue
|
||||
}: PrecisePdfPageProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const textLayerRef = useRef<HTMLDivElement>(null);
|
||||
const [displayWidth, setDisplayWidth] = useState(0);
|
||||
const [renderError, setRenderError] = useState("");
|
||||
const [renderState, setRenderState] = useState<
|
||||
"idle" | "queued" | "rendering" | "rendered"
|
||||
>("idle");
|
||||
const nearViewport = useNearViewport(containerRef);
|
||||
const dimensions = getPdfPageCssDimensions(
|
||||
descriptor.width,
|
||||
descriptor.height
|
||||
);
|
||||
const pageStyle = {
|
||||
width: `${dimensions.width}px`,
|
||||
aspectRatio: `${descriptor.width} / ${descriptor.height}`
|
||||
} satisfies CSSProperties;
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateWidth = () => setDisplayWidth(container.clientWidth);
|
||||
updateWidth();
|
||||
const observer = new ResizeObserver(updateWidth);
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const updateVisibility = useCallback(
|
||||
(visible: boolean) => {
|
||||
renderQueue.setVisible(descriptor.pageNumber, visible);
|
||||
},
|
||||
[descriptor.pageNumber, renderQueue]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
if (!("IntersectionObserver" in window)) {
|
||||
updateVisibility(true);
|
||||
return () => updateVisibility(false);
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
updateVisibility(Boolean(entry?.isIntersecting));
|
||||
});
|
||||
observer.observe(container);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
updateVisibility(false);
|
||||
};
|
||||
}, [updateVisibility]);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const textContainer = textLayerRef.current;
|
||||
if (
|
||||
!nearViewport ||
|
||||
displayWidth <= 0 ||
|
||||
!canvas ||
|
||||
!textContainer
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
let page: PDFPageProxy | undefined;
|
||||
let renderTask: RenderTask | undefined;
|
||||
let textLayer: TextLayer | undefined;
|
||||
|
||||
setRenderError("");
|
||||
setRenderState("queued");
|
||||
const cancelQueuedRender = renderQueue.schedule(
|
||||
descriptor.pageNumber,
|
||||
async () => {
|
||||
try {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
setRenderState("rendering");
|
||||
const loadedPage = await document.getPage(
|
||||
descriptor.pageNumber
|
||||
);
|
||||
page = loadedPage;
|
||||
const outputScale = Math.min(
|
||||
window.devicePixelRatio || 1,
|
||||
2
|
||||
);
|
||||
const renderScale = getPdfCanvasRenderScale(
|
||||
descriptor.width,
|
||||
displayWidth,
|
||||
outputScale
|
||||
);
|
||||
const displayScale = displayWidth / descriptor.width;
|
||||
const renderViewport = page.getViewport({
|
||||
scale: renderScale
|
||||
});
|
||||
const displayViewport = page.getViewport({
|
||||
scale: displayScale
|
||||
});
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
throw new Error("浏览器无法创建 PDF Canvas");
|
||||
}
|
||||
|
||||
canvas.width = Math.ceil(renderViewport.width);
|
||||
canvas.height = Math.ceil(renderViewport.height);
|
||||
canvas.style.width = `${displayViewport.width}px`;
|
||||
canvas.style.height = `${displayViewport.height}px`;
|
||||
textContainer.replaceChildren();
|
||||
|
||||
renderTask = page.render({
|
||||
canvas,
|
||||
canvasContext: context,
|
||||
viewport: renderViewport
|
||||
});
|
||||
await renderTask.promise;
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
setRenderState("rendered");
|
||||
|
||||
textLayer = new TextLayer({
|
||||
container: textContainer,
|
||||
textContentSource: page.streamTextContent(),
|
||||
viewport: displayViewport
|
||||
});
|
||||
await textLayer.render();
|
||||
} catch (reason: unknown) {
|
||||
if (
|
||||
disposed ||
|
||||
reason instanceof RenderingCancelledException
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setRenderError(
|
||||
reason instanceof Error
|
||||
? reason.message
|
||||
: "PDF 页面渲染失败"
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
cancelQueuedRender();
|
||||
renderTask?.cancel();
|
||||
textLayer?.cancel();
|
||||
page?.cleanup();
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
textContainer.replaceChildren();
|
||||
setRenderState("idle");
|
||||
};
|
||||
}, [
|
||||
descriptor.height,
|
||||
descriptor.pageNumber,
|
||||
descriptor.width,
|
||||
displayWidth,
|
||||
document,
|
||||
nearViewport,
|
||||
renderQueue
|
||||
]);
|
||||
|
||||
return (
|
||||
<article
|
||||
ref={containerRef}
|
||||
className="precise-pdf-page"
|
||||
style={pageStyle}
|
||||
aria-label={`PDF 第 ${descriptor.pageNumber} 页`}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={renderState === "rendered" ? "is-rendered" : ""}
|
||||
width={0}
|
||||
height={0}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div ref={textLayerRef} className="textLayer" />
|
||||
{renderState !== "rendered" ? (
|
||||
<span className="precise-pdf-page-placeholder">
|
||||
{renderState === "rendering"
|
||||
? `正在渲染第 ${descriptor.pageNumber} 页…`
|
||||
: `第 ${descriptor.pageNumber} 页`}
|
||||
</span>
|
||||
) : null}
|
||||
{renderError ? (
|
||||
<span className="precise-pdf-page-error">{renderError}</span>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function PrecisePdfPreview({
|
||||
blob,
|
||||
onPageCount
|
||||
}: PrecisePdfPreviewProps) {
|
||||
const [document, setDocument] = useState<PDFDocumentProxy>();
|
||||
const [pages, setPages] = useState<PdfPageDescriptor[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const renderQueue = useMemo(
|
||||
() => new PdfPageRenderQueue(1),
|
||||
[document]
|
||||
);
|
||||
|
||||
useEffect(() => () => renderQueue.dispose(), [renderQueue]);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let loadingTask:
|
||||
| ReturnType<typeof getDocument>
|
||||
| undefined;
|
||||
|
||||
setDocument(undefined);
|
||||
setPages([]);
|
||||
setError("");
|
||||
|
||||
void blob
|
||||
.arrayBuffer()
|
||||
.then((buffer) => {
|
||||
if (disposed) {
|
||||
return undefined;
|
||||
}
|
||||
loadingTask = getDocument({
|
||||
data: new Uint8Array(buffer)
|
||||
});
|
||||
return loadingTask.promise;
|
||||
})
|
||||
.then(async (loadedDocument) => {
|
||||
if (!loadedDocument || disposed) {
|
||||
return;
|
||||
}
|
||||
const descriptors = await Promise.all(
|
||||
Array.from(
|
||||
{ length: loadedDocument.numPages },
|
||||
async (_, index) => {
|
||||
const page = await loadedDocument.getPage(index + 1);
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
page.cleanup();
|
||||
return {
|
||||
pageNumber: index + 1,
|
||||
width: viewport.width,
|
||||
height: viewport.height
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
setDocument(loadedDocument);
|
||||
setPages(descriptors);
|
||||
onPageCount?.(loadedDocument.numPages);
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!disposed) {
|
||||
setError(
|
||||
reason instanceof Error ? reason.message : "无法读取 PDF"
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
void loadingTask?.destroy();
|
||||
};
|
||||
}, [blob, onPageCount]);
|
||||
|
||||
if (error) {
|
||||
return <div className="precise-pdf-status is-error">{error}</div>;
|
||||
}
|
||||
if (!document || pages.length === 0) {
|
||||
return <div className="precise-pdf-status">正在读取精确预览…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="precise-pdf-document">
|
||||
{pages.map((page) => (
|
||||
<PrecisePdfPage
|
||||
key={page.pageNumber}
|
||||
document={document}
|
||||
descriptor={page}
|
||||
renderQueue={renderQueue}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
export function createSvgDataUrl(svgMarkup: string) {
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgMarkup)}`;
|
||||
}
|
||||
|
||||
export interface MermaidBoxSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export type MermaidOutputMode = "inline-svg" | "svg-image";
|
||||
|
||||
export function resolveMermaidOutputMode(
|
||||
search: string
|
||||
): MermaidOutputMode {
|
||||
return new URLSearchParams(search).get("mermaid-output") ===
|
||||
"inline-svg"
|
||||
? "inline-svg"
|
||||
: "svg-image";
|
||||
}
|
||||
|
||||
export function getLockedMermaidBoxSize(
|
||||
size: MermaidBoxSize
|
||||
): MermaidBoxSize | undefined {
|
||||
if (
|
||||
!Number.isFinite(size.width) ||
|
||||
!Number.isFinite(size.height) ||
|
||||
size.width <= 0 ||
|
||||
size.height <= 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
export function getMermaidImageAlt(
|
||||
ariaLabel: string | null,
|
||||
title: string | null
|
||||
) {
|
||||
return ariaLabel?.trim() || title?.trim() || "Mermaid 图表";
|
||||
}
|
||||
|
||||
export function replaceMermaidSvgWithImages(container: ParentNode) {
|
||||
const diagrams = Array.from(
|
||||
container.querySelectorAll<HTMLElement>(".mermaid")
|
||||
);
|
||||
|
||||
for (const diagram of diagrams) {
|
||||
const svg = diagram.querySelector<SVGSVGElement>("svg");
|
||||
if (!svg) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const documentWindow = svg.ownerDocument.defaultView;
|
||||
const svgRect = svg.getBoundingClientRect();
|
||||
const diagramRect = diagram.getBoundingClientRect();
|
||||
const computedStyle = documentWindow?.getComputedStyle(svg);
|
||||
const lockedSvgSize = getLockedMermaidBoxSize(svgRect);
|
||||
const lockedDiagramSize = getLockedMermaidBoxSize(diagramRect);
|
||||
const image = svg.ownerDocument.createElement("img");
|
||||
image.className = "mermaid-svg-image";
|
||||
image.alt = getMermaidImageAlt(
|
||||
svg.getAttribute("aria-label"),
|
||||
svg.querySelector("title")?.textContent ?? null
|
||||
);
|
||||
image.src = createSvgDataUrl(
|
||||
new XMLSerializer().serializeToString(svg)
|
||||
);
|
||||
image.style.cssText = svg.style.cssText;
|
||||
if (computedStyle?.display) {
|
||||
image.style.setProperty("display", computedStyle.display, "important");
|
||||
}
|
||||
if (computedStyle?.verticalAlign) {
|
||||
image.style.setProperty(
|
||||
"vertical-align",
|
||||
computedStyle.verticalAlign,
|
||||
"important"
|
||||
);
|
||||
}
|
||||
if (lockedSvgSize) {
|
||||
const width = `${lockedSvgSize.width}px`;
|
||||
const height = `${lockedSvgSize.height}px`;
|
||||
image.style.setProperty("width", width, "important");
|
||||
image.style.setProperty("height", height, "important");
|
||||
image.style.setProperty("max-width", width, "important");
|
||||
image.style.setProperty("max-height", height, "important");
|
||||
image.setAttribute("width", String(lockedSvgSize.width));
|
||||
image.setAttribute("height", String(lockedSvgSize.height));
|
||||
}
|
||||
if (lockedDiagramSize) {
|
||||
diagram.style.setProperty("box-sizing", "border-box", "important");
|
||||
diagram.style.setProperty(
|
||||
"height",
|
||||
`${lockedDiagramSize.height}px`,
|
||||
"important"
|
||||
);
|
||||
}
|
||||
svg.replaceWith(image);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
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 { renderMermaidDefinitions } from "./mermaid-renderer";
|
||||
import { replaceMermaidSvgWithImages } from "./mermaid-static-image";
|
||||
import type { MermaidOutputMode } from "./mermaid-static-image";
|
||||
import "./paged-table-handler";
|
||||
import {
|
||||
buildPagedMediaCss,
|
||||
documentBaseCss,
|
||||
documentGeometryCss,
|
||||
formatPageNumber,
|
||||
type PagedPreviewPayload
|
||||
} from "./paged-preview";
|
||||
import type { PagedRenderTarget } from "./paged-render-target";
|
||||
|
||||
export interface PagedDocumentRenderResult {
|
||||
pageCount: number;
|
||||
contentHeight: number;
|
||||
mermaidErrors: string[];
|
||||
timings: PagedDocumentTimings;
|
||||
}
|
||||
|
||||
export interface PagedDocumentTimings {
|
||||
setupMs: number;
|
||||
mermaidMs: number;
|
||||
mermaidFitMs: number;
|
||||
mermaidConversionMs: number;
|
||||
resourceWaitMs: number;
|
||||
paginationMs: number;
|
||||
finalizeMs: number;
|
||||
totalMs: number;
|
||||
}
|
||||
|
||||
export interface PagedDocumentRenderOptions {
|
||||
target: PagedRenderTarget;
|
||||
shouldContinue?: () => boolean;
|
||||
mermaidOutput?: MermaidOutputMode;
|
||||
}
|
||||
|
||||
function waitForImages(container: ParentNode) {
|
||||
const images = Array.from(container.querySelectorAll("img"));
|
||||
return Promise.all(
|
||||
images.map(async (image) => {
|
||||
if (image.complete) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await image.decode();
|
||||
} catch {
|
||||
await new Promise<void>((resolve) => {
|
||||
image.addEventListener("load", () => resolve(), { once: true });
|
||||
image.addEventListener("error", () => resolve(), { once: true });
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function stylesheet(
|
||||
documentRef: Document,
|
||||
css: string,
|
||||
name: string
|
||||
) {
|
||||
return {
|
||||
[`${documentRef.location.href}#${name}`]: css
|
||||
};
|
||||
}
|
||||
|
||||
function applyPageNumbers(
|
||||
container: ParentNode,
|
||||
payload: PagedPreviewPayload,
|
||||
totalPages: number
|
||||
) {
|
||||
if (!payload.exportConfig.footer.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const alignment = payload.exportConfig.footer.alignment;
|
||||
const pages = Array.from(
|
||||
container.querySelectorAll<HTMLElement>(".pagedjs_page")
|
||||
);
|
||||
|
||||
for (const [pageIndex, page] of pages.entries()) {
|
||||
const content = page.querySelector<HTMLElement>(
|
||||
`.pagedjs_margin-bottom-${alignment} .pagedjs_margin-content`
|
||||
);
|
||||
if (!content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
content.textContent = formatPageNumber(
|
||||
payload.exportConfig.footer,
|
||||
pageIndex,
|
||||
totalPages
|
||||
);
|
||||
content.setAttribute("data-page-number-rendered", "true");
|
||||
}
|
||||
}
|
||||
|
||||
function removeTrailingPdfPageBreak(container: ParentNode) {
|
||||
const pages = Array.from(
|
||||
container.querySelectorAll<HTMLElement>(".pagedjs_page")
|
||||
);
|
||||
pages
|
||||
.at(-1)
|
||||
?.style.setProperty("break-after", "auto", "important");
|
||||
}
|
||||
|
||||
export class PagedDocumentRuntime {
|
||||
private activePreviewer: Previewer | undefined;
|
||||
private mermaidRenderSequence = 0;
|
||||
private mermaidPromise:
|
||||
| Promise<(typeof import("mermaid"))["default"]>
|
||||
| undefined;
|
||||
|
||||
constructor(private readonly root: HTMLElement) {}
|
||||
|
||||
destroy() {
|
||||
if (!this.activePreviewer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activePreviewer.chunker.destroy();
|
||||
this.activePreviewer.polisher.destroy();
|
||||
this.activePreviewer = undefined;
|
||||
}
|
||||
|
||||
private loadMermaid() {
|
||||
if (!this.mermaidPromise) {
|
||||
this.mermaidPromise = import("mermaid").then(
|
||||
({ default: mermaid }) => {
|
||||
mermaid.initialize(createMermaidSiteConfig());
|
||||
return mermaid;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return this.mermaidPromise;
|
||||
}
|
||||
|
||||
private async renderMermaid(
|
||||
container: DocumentFragment,
|
||||
payload: PagedPreviewPayload
|
||||
) {
|
||||
if (!payload.features.includes("mermaid")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const nodes = Array.from(
|
||||
container.querySelectorAll<HTMLElement>(
|
||||
".mermaid[data-mermaid-pending]"
|
||||
)
|
||||
);
|
||||
if (nodes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const mermaid = await this.loadMermaid();
|
||||
const outcomes = await renderMermaidDefinitions(
|
||||
nodes.map((node) => node.textContent ?? ""),
|
||||
async (definition) => {
|
||||
this.mermaidRenderSequence += 1;
|
||||
return mermaid.render(
|
||||
`mermaid-paged-document-${this.mermaidRenderSequence}`,
|
||||
definition
|
||||
);
|
||||
}
|
||||
);
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const [index, outcome] of outcomes.entries()) {
|
||||
const node = nodes[index];
|
||||
if (!node) {
|
||||
continue;
|
||||
}
|
||||
|
||||
node.removeAttribute("data-mermaid-pending");
|
||||
if (outcome.success) {
|
||||
node.innerHTML = outcome.svg;
|
||||
outcome.bindFunctions?.(node);
|
||||
continue;
|
||||
}
|
||||
|
||||
const message =
|
||||
outcome.error instanceof Error
|
||||
? outcome.error.message
|
||||
: "未知错误";
|
||||
node.classList.add("mermaid-error");
|
||||
node.textContent =
|
||||
`Mermaid 图表 ${index + 1} 渲染失败:${message}`;
|
||||
errors.push(`图表 ${index + 1}:${message}`);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
async render(
|
||||
payload: PagedPreviewPayload,
|
||||
options: PagedDocumentRenderOptions
|
||||
): Promise<PagedDocumentRenderResult | undefined> {
|
||||
const totalStartedAt = performance.now();
|
||||
const shouldContinue = options.shouldContinue ?? (() => true);
|
||||
if (!shouldContinue()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const setupStartedAt = performance.now();
|
||||
const documentRef = this.root.ownerDocument;
|
||||
this.destroy();
|
||||
this.root.replaceChildren();
|
||||
documentRef.documentElement.dataset.renderTarget = options.target;
|
||||
documentRef.documentElement.lang =
|
||||
payload.metadata.language || "zh-CN";
|
||||
documentRef.title =
|
||||
payload.metadata.title || "Markdown 分页文档";
|
||||
|
||||
const template = documentRef.createElement("template");
|
||||
template.innerHTML = payload.articleHtml;
|
||||
const content = template.content;
|
||||
const setupMs = performance.now() - setupStartedAt;
|
||||
|
||||
const mermaidStartedAt = performance.now();
|
||||
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;
|
||||
|
||||
const mermaidConversionStartedAt = performance.now();
|
||||
if (options.mermaidOutput === "svg-image") {
|
||||
replaceMermaidSvgWithImages(content);
|
||||
}
|
||||
const mermaidConversionMs =
|
||||
performance.now() - mermaidConversionStartedAt;
|
||||
|
||||
if (!shouldContinue()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const resourceWaitStartedAt = performance.now();
|
||||
await documentRef.fonts.ready;
|
||||
await waitForImages(content);
|
||||
const resourceWaitMs = performance.now() - resourceWaitStartedAt;
|
||||
|
||||
const paginationStartedAt = performance.now();
|
||||
const previewer = new Previewer();
|
||||
this.activePreviewer = previewer;
|
||||
const flow = await previewer.preview(
|
||||
content,
|
||||
[
|
||||
stylesheet(documentRef, documentBaseCss, "document-base"),
|
||||
stylesheet(documentRef, highlightCss, "highlight"),
|
||||
stylesheet(documentRef, katexCss, "katex"),
|
||||
stylesheet(documentRef, payload.themeCss, "theme"),
|
||||
stylesheet(
|
||||
documentRef,
|
||||
documentGeometryCss,
|
||||
"document-geometry"
|
||||
),
|
||||
stylesheet(
|
||||
documentRef,
|
||||
buildPagedMediaCss(payload.exportConfig, payload),
|
||||
"paged-media"
|
||||
)
|
||||
],
|
||||
this.root
|
||||
);
|
||||
const paginationMs = performance.now() - paginationStartedAt;
|
||||
|
||||
if (!shouldContinue()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const finalizeStartedAt = performance.now();
|
||||
applyPageNumbers(this.root, payload, flow.total);
|
||||
if (options.target === "pdf") {
|
||||
removeTrailingPdfPageBreak(this.root);
|
||||
}
|
||||
const finalizeMs = performance.now() - finalizeStartedAt;
|
||||
|
||||
return {
|
||||
pageCount: flow.total,
|
||||
contentHeight: Math.ceil(
|
||||
Math.max(
|
||||
documentRef.documentElement.scrollHeight,
|
||||
documentRef.body.scrollHeight,
|
||||
this.root.scrollHeight
|
||||
)
|
||||
),
|
||||
mermaidErrors,
|
||||
timings: {
|
||||
setupMs,
|
||||
mermaidMs,
|
||||
mermaidFitMs,
|
||||
mermaidConversionMs,
|
||||
resourceWaitMs,
|
||||
paginationMs,
|
||||
finalizeMs,
|
||||
totalMs: performance.now() - totalStartedAt
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__mdToPdfRender?: (
|
||||
payload: PagedPreviewPayload,
|
||||
target?: PagedRenderTarget
|
||||
) => Promise<PagedDocumentRenderResult>;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,12 @@
|
||||
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 { renderMermaidDefinitions } from "./mermaid-renderer";
|
||||
import "./paged-table-handler";
|
||||
import { PagedDocumentRuntime } from "./paged-document-runtime";
|
||||
import {
|
||||
buildPagedMediaCss,
|
||||
documentBaseCss,
|
||||
documentGeometryCss,
|
||||
formatPageNumber,
|
||||
isPagedPreviewRenderRequest,
|
||||
PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||
type PagedPreviewFrameMessage,
|
||||
type PagedPreviewPayload
|
||||
} from "./paged-preview";
|
||||
import { resolvePagedRenderTarget } from "./paged-render-target";
|
||||
import { resolveMermaidOutputMode } from "./mermaid-static-image";
|
||||
|
||||
function getPreviewRoot() {
|
||||
const element = document.querySelector<HTMLElement>("#preview-root");
|
||||
@@ -25,151 +17,18 @@ function getPreviewRoot() {
|
||||
}
|
||||
|
||||
const previewRoot = getPreviewRoot();
|
||||
document.documentElement.dataset.renderTarget = "preview";
|
||||
const renderTarget = resolvePagedRenderTarget(window.location.search);
|
||||
const mermaidOutput = resolveMermaidOutputMode(window.location.search);
|
||||
const runtime = new PagedDocumentRuntime(previewRoot);
|
||||
document.documentElement.dataset.renderTarget = renderTarget;
|
||||
|
||||
let latestRequestId = 0;
|
||||
let renderQueue = Promise.resolve();
|
||||
let activePreviewer: Previewer | undefined;
|
||||
let mermaidRenderSequence = 0;
|
||||
let mermaidPromise: Promise<
|
||||
(typeof import("mermaid"))["default"]
|
||||
> | undefined;
|
||||
|
||||
function post(message: PagedPreviewFrameMessage) {
|
||||
window.parent.postMessage(message, window.location.origin);
|
||||
}
|
||||
|
||||
function destroyActivePreviewer() {
|
||||
if (!activePreviewer) {
|
||||
return;
|
||||
}
|
||||
|
||||
activePreviewer.chunker.destroy();
|
||||
activePreviewer.polisher.destroy();
|
||||
activePreviewer = undefined;
|
||||
}
|
||||
|
||||
function loadMermaid() {
|
||||
if (!mermaidPromise) {
|
||||
mermaidPromise = import("mermaid").then(({ default: mermaid }) => {
|
||||
mermaid.initialize(createMermaidSiteConfig());
|
||||
return mermaid;
|
||||
});
|
||||
}
|
||||
|
||||
return mermaidPromise;
|
||||
}
|
||||
|
||||
async function renderMermaid(
|
||||
container: DocumentFragment,
|
||||
payload: PagedPreviewPayload
|
||||
) {
|
||||
if (!payload.features.includes("mermaid")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const nodes = Array.from(
|
||||
container.querySelectorAll<HTMLElement>(
|
||||
".mermaid[data-mermaid-pending]"
|
||||
)
|
||||
);
|
||||
if (nodes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const mermaid = await loadMermaid();
|
||||
const outcomes = await renderMermaidDefinitions(
|
||||
nodes.map((node) => node.textContent ?? ""),
|
||||
async (definition) => {
|
||||
mermaidRenderSequence += 1;
|
||||
return mermaid.render(
|
||||
`mermaid-paged-preview-${mermaidRenderSequence}`,
|
||||
definition
|
||||
);
|
||||
}
|
||||
);
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const [index, outcome] of outcomes.entries()) {
|
||||
const node = nodes[index];
|
||||
if (!node) {
|
||||
continue;
|
||||
}
|
||||
|
||||
node.removeAttribute("data-mermaid-pending");
|
||||
if (outcome.success) {
|
||||
node.innerHTML = outcome.svg;
|
||||
outcome.bindFunctions?.(node);
|
||||
continue;
|
||||
}
|
||||
|
||||
const message =
|
||||
outcome.error instanceof Error ? outcome.error.message : "未知错误";
|
||||
node.classList.add("mermaid-error");
|
||||
node.textContent = `Mermaid 图表 ${index + 1} 渲染失败:${message}`;
|
||||
errors.push(`图表 ${index + 1}:${message}`);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
async function waitForImages(container: ParentNode) {
|
||||
const images = Array.from(container.querySelectorAll("img"));
|
||||
await Promise.all(
|
||||
images.map(async (image) => {
|
||||
if (image.complete) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await image.decode();
|
||||
} catch {
|
||||
await new Promise<void>((resolve) => {
|
||||
image.addEventListener("load", () => resolve(), { once: true });
|
||||
image.addEventListener("error", () => resolve(), { once: true });
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function stylesheet(css: string, name: string) {
|
||||
return {
|
||||
[`${window.location.href}#${name}`]: css
|
||||
};
|
||||
}
|
||||
|
||||
function applyPageNumbers(
|
||||
container: ParentNode,
|
||||
payload: PagedPreviewPayload,
|
||||
totalPages: number
|
||||
) {
|
||||
if (!payload.exportConfig.footer.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const alignment = payload.exportConfig.footer.alignment;
|
||||
const pages = Array.from(
|
||||
container.querySelectorAll<HTMLElement>(".pagedjs_page")
|
||||
);
|
||||
|
||||
for (const [pageIndex, page] of pages.entries()) {
|
||||
const content = page.querySelector<HTMLElement>(
|
||||
`.pagedjs_margin-bottom-${alignment} .pagedjs_margin-content`
|
||||
);
|
||||
if (!content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
content.textContent = formatPageNumber(
|
||||
payload.exportConfig.footer,
|
||||
pageIndex,
|
||||
totalPages
|
||||
);
|
||||
content.setAttribute("data-page-number-rendered", "true");
|
||||
}
|
||||
}
|
||||
|
||||
async function renderPagedPreview(
|
||||
requestId: number,
|
||||
payload: PagedPreviewPayload
|
||||
@@ -184,65 +43,38 @@ async function renderPagedPreview(
|
||||
requestId
|
||||
});
|
||||
|
||||
destroyActivePreviewer();
|
||||
previewRoot.replaceChildren();
|
||||
document.documentElement.lang = payload.metadata.language || "zh-CN";
|
||||
document.title = payload.metadata.title || "Markdown 分页预览";
|
||||
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = payload.articleHtml;
|
||||
const content = template.content;
|
||||
const mermaidErrors = await renderMermaid(content, payload);
|
||||
fitOversizedMermaidToPage(content, payload.exportConfig);
|
||||
|
||||
if (requestId !== latestRequestId) {
|
||||
const result = await runtime.render(payload, {
|
||||
target: renderTarget,
|
||||
mermaidOutput,
|
||||
shouldContinue: () => requestId === latestRequestId
|
||||
});
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
await document.fonts.ready;
|
||||
await waitForImages(content);
|
||||
|
||||
const previewer = new Previewer();
|
||||
activePreviewer = previewer;
|
||||
const flow = await previewer.preview(
|
||||
content,
|
||||
[
|
||||
stylesheet(documentBaseCss, "document-base"),
|
||||
stylesheet(highlightCss, "highlight"),
|
||||
stylesheet(katexCss, "katex"),
|
||||
stylesheet(payload.themeCss, "theme"),
|
||||
stylesheet(documentGeometryCss, "document-geometry"),
|
||||
stylesheet(
|
||||
buildPagedMediaCss(payload.exportConfig, payload),
|
||||
"paged-media"
|
||||
)
|
||||
],
|
||||
previewRoot
|
||||
);
|
||||
applyPageNumbers(previewRoot, payload, flow.total);
|
||||
|
||||
if (requestId !== latestRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentHeight = Math.ceil(
|
||||
Math.max(
|
||||
document.documentElement.scrollHeight,
|
||||
document.body.scrollHeight,
|
||||
previewRoot.scrollHeight
|
||||
)
|
||||
);
|
||||
|
||||
post({
|
||||
scope: PAGED_PREVIEW_MESSAGE_SCOPE,
|
||||
type: "rendered",
|
||||
requestId,
|
||||
pageCount: flow.total,
|
||||
contentHeight,
|
||||
mermaidErrors
|
||||
...result
|
||||
});
|
||||
}
|
||||
|
||||
window.__mdToPdfRender = async (
|
||||
payload,
|
||||
target = renderTarget
|
||||
) => {
|
||||
const result = await runtime.render(payload, {
|
||||
target,
|
||||
mermaidOutput
|
||||
});
|
||||
if (!result) {
|
||||
throw new Error("分页文档渲染已取消");
|
||||
}
|
||||
return result;
|
||||
};
|
||||
document.documentElement.dataset.runtimeReady = "true";
|
||||
|
||||
window.addEventListener("message", (event: MessageEvent<unknown>) => {
|
||||
if (
|
||||
event.origin !== window.location.origin ||
|
||||
|
||||
@@ -368,6 +368,9 @@ html[data-render-target="preview"] body {
|
||||
|
||||
html[data-render-target="pdf"],
|
||||
html[data-render-target="pdf"] body {
|
||||
height: auto !important;
|
||||
min-height: 0 !important;
|
||||
overflow: visible !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
@@ -382,10 +385,6 @@ html[data-render-target="pdf"] .pagedjs_page {
|
||||
box-shadow: none;
|
||||
break-after: page;
|
||||
}
|
||||
|
||||
html[data-render-target="pdf"] .pagedjs_page:last-child {
|
||||
break-after: auto;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export type PagedRenderTarget = "preview" | "pdf";
|
||||
|
||||
export function resolvePagedRenderTarget(
|
||||
search: string
|
||||
): PagedRenderTarget {
|
||||
const target = new URLSearchParams(search).get("target");
|
||||
return target === "pdf" ? "pdf" : "preview";
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { ExportConfig } from "@md-to-pdf/core";
|
||||
|
||||
export interface PdfExportRequest {
|
||||
markdown: string;
|
||||
fileName: string;
|
||||
language: string;
|
||||
exportConfig: ExportConfig;
|
||||
}
|
||||
|
||||
export interface PdfExportResult {
|
||||
blob: Blob;
|
||||
fileName: string;
|
||||
pageCount: number | undefined;
|
||||
mermaidErrorCount: number;
|
||||
}
|
||||
|
||||
export interface CachedPdfExport {
|
||||
key: string;
|
||||
result: PdfExportResult;
|
||||
}
|
||||
|
||||
export function createPdfExportCacheKey(request: PdfExportRequest) {
|
||||
return JSON.stringify([
|
||||
request.markdown,
|
||||
request.fileName,
|
||||
request.language,
|
||||
request.exportConfig
|
||||
]);
|
||||
}
|
||||
|
||||
export function getCachedPdfExport(
|
||||
cache: CachedPdfExport | undefined,
|
||||
key: string
|
||||
) {
|
||||
return cache?.key === key ? cache.result : undefined;
|
||||
}
|
||||
|
||||
export async function getOrRequestPdfExport(
|
||||
request: PdfExportRequest,
|
||||
cache: CachedPdfExport | undefined,
|
||||
requester: (
|
||||
request: PdfExportRequest
|
||||
) => Promise<PdfExportResult> = requestPdfExport
|
||||
): Promise<CachedPdfExport> {
|
||||
const key = createPdfExportCacheKey(request);
|
||||
const cached = getCachedPdfExport(cache, key);
|
||||
return {
|
||||
key,
|
||||
result: cached ?? (await requester(request))
|
||||
};
|
||||
}
|
||||
|
||||
function parseNumericHeader(value: string | null) {
|
||||
if (value === null) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
export function parseContentDispositionFileName(
|
||||
contentDisposition: string | null
|
||||
) {
|
||||
const encoded = contentDisposition?.match(
|
||||
/filename\*=UTF-8''([^;]+)/i
|
||||
)?.[1];
|
||||
if (encoded) {
|
||||
try {
|
||||
return decodeURIComponent(encoded);
|
||||
} catch {
|
||||
// 使用兼容文件名回退。
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
contentDisposition
|
||||
?.match(/filename="([^"]+)"/i)?.[1]
|
||||
?.trim() || "document.pdf"
|
||||
);
|
||||
}
|
||||
|
||||
async function readErrorMessage(response: Response) {
|
||||
try {
|
||||
const payload = (await response.json()) as {
|
||||
message?: unknown;
|
||||
};
|
||||
if (typeof payload.message === "string") {
|
||||
return payload.message;
|
||||
}
|
||||
} catch {
|
||||
// 非 JSON 错误响应使用通用提示。
|
||||
}
|
||||
return "PDF 导出失败";
|
||||
}
|
||||
|
||||
export async function requestPdfExport(
|
||||
request: PdfExportRequest,
|
||||
fetcher: typeof fetch = fetch
|
||||
): Promise<PdfExportResult> {
|
||||
const response = await fetcher("/api/pdf", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(request)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response));
|
||||
}
|
||||
|
||||
return {
|
||||
blob: await response.blob(),
|
||||
fileName: parseContentDispositionFileName(
|
||||
response.headers.get("content-disposition")
|
||||
),
|
||||
pageCount: parseNumericHeader(
|
||||
response.headers.get("x-pdf-page-count")
|
||||
),
|
||||
mermaidErrorCount:
|
||||
parseNumericHeader(
|
||||
response.headers.get("x-mermaid-error-count")
|
||||
) ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
export function downloadPdf(blob: Blob, fileName: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
anchor.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export const PDF_POINTS_PER_INCH = 72;
|
||||
export const CSS_PIXELS_PER_INCH = 96;
|
||||
export const PDF_PAGE_CSS_SCALE =
|
||||
CSS_PIXELS_PER_INCH / PDF_POINTS_PER_INCH;
|
||||
|
||||
export function getPdfPageCssDimensions(
|
||||
widthPoints: number,
|
||||
heightPoints: number
|
||||
) {
|
||||
return {
|
||||
width: widthPoints * PDF_PAGE_CSS_SCALE,
|
||||
height: heightPoints * PDF_PAGE_CSS_SCALE
|
||||
};
|
||||
}
|
||||
|
||||
export function getPdfCanvasRenderScale(
|
||||
widthPoints: number,
|
||||
displayWidthCssPixels: number,
|
||||
devicePixelRatio: number
|
||||
) {
|
||||
if (
|
||||
widthPoints <= 0 ||
|
||||
displayWidthCssPixels <= 0 ||
|
||||
devicePixelRatio <= 0
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
return (displayWidthCssPixels / widthPoints) * devicePixelRatio;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
export type PdfPageRenderTask = () => Promise<void>;
|
||||
|
||||
interface QueuedRender {
|
||||
id: symbol;
|
||||
pageNumber: number;
|
||||
run: PdfPageRenderTask;
|
||||
cancelled: boolean;
|
||||
}
|
||||
|
||||
export class PdfPageRenderQueue {
|
||||
private readonly pending: QueuedRender[] = [];
|
||||
private readonly visiblePages = new Set<number>();
|
||||
private active = 0;
|
||||
private focusPage = 1;
|
||||
private scrollDirection: 1 | -1 = 1;
|
||||
private disposed = false;
|
||||
|
||||
constructor(private readonly concurrency = 1) {
|
||||
if (!Number.isInteger(concurrency) || concurrency < 1) {
|
||||
throw new Error("PDF 页面渲染并发数必须是正整数");
|
||||
}
|
||||
}
|
||||
|
||||
schedule(pageNumber: number, run: PdfPageRenderTask) {
|
||||
const task: QueuedRender = {
|
||||
id: Symbol(`pdf-page-${pageNumber}`),
|
||||
pageNumber,
|
||||
run,
|
||||
cancelled: false
|
||||
};
|
||||
this.pending.push(task);
|
||||
this.pump();
|
||||
|
||||
return () => {
|
||||
task.cancelled = true;
|
||||
const index = this.pending.findIndex(
|
||||
(candidate) => candidate.id === task.id
|
||||
);
|
||||
if (index >= 0) {
|
||||
this.pending.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setVisible(pageNumber: number, visible: boolean) {
|
||||
if (visible) {
|
||||
if (pageNumber !== this.focusPage) {
|
||||
this.scrollDirection =
|
||||
pageNumber > this.focusPage ? 1 : -1;
|
||||
this.focusPage = pageNumber;
|
||||
}
|
||||
this.visiblePages.add(pageNumber);
|
||||
} else {
|
||||
this.visiblePages.delete(pageNumber);
|
||||
}
|
||||
this.pump();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.disposed = true;
|
||||
for (const task of this.pending) {
|
||||
task.cancelled = true;
|
||||
}
|
||||
this.pending.length = 0;
|
||||
this.visiblePages.clear();
|
||||
}
|
||||
|
||||
private comparePriority(left: QueuedRender, right: QueuedRender) {
|
||||
const leftPriority = this.priority(left.pageNumber);
|
||||
const rightPriority = this.priority(right.pageNumber);
|
||||
return (
|
||||
leftPriority.group - rightPriority.group ||
|
||||
leftPriority.distance - rightPriority.distance ||
|
||||
left.pageNumber - right.pageNumber
|
||||
);
|
||||
}
|
||||
|
||||
private priority(pageNumber: number) {
|
||||
if (this.visiblePages.has(pageNumber)) {
|
||||
return { group: 0, distance: 0 };
|
||||
}
|
||||
|
||||
const delta = pageNumber - this.focusPage;
|
||||
const isAhead = delta * this.scrollDirection >= 0;
|
||||
return {
|
||||
group: isAhead ? 1 : 2,
|
||||
distance: Math.abs(delta)
|
||||
};
|
||||
}
|
||||
|
||||
private pump() {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (this.active < this.concurrency) {
|
||||
this.pending.sort((left, right) =>
|
||||
this.comparePriority(left, right)
|
||||
);
|
||||
const task = this.pending.shift();
|
||||
if (!task) {
|
||||
return;
|
||||
}
|
||||
if (task.cancelled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.active += 1;
|
||||
void task
|
||||
.run()
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
this.active -= 1;
|
||||
this.pump();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,6 +184,31 @@ h1 {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview-mode-control {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
padding: 2px;
|
||||
border: 1px solid #c9d4ce;
|
||||
border-radius: 8px;
|
||||
background: #eef2ef;
|
||||
}
|
||||
|
||||
.preview-mode-control button {
|
||||
min-height: 28px;
|
||||
padding: 0 9px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #68766f;
|
||||
font-size: 0.69rem;
|
||||
}
|
||||
|
||||
.preview-mode-control button.is-active {
|
||||
background: #fff;
|
||||
color: #356c55;
|
||||
box-shadow: 0 1px 4px rgb(37 49 43 / 12%);
|
||||
}
|
||||
|
||||
.theme-control {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
@@ -290,6 +315,145 @@ textarea:focus {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.precise-pdf-document {
|
||||
display: flex;
|
||||
min-width: 100%;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 34px 30px;
|
||||
}
|
||||
|
||||
.precise-pdf-page {
|
||||
position: relative;
|
||||
flex: none;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
box-shadow: 0 18px 48px rgb(37 49 43 / 16%);
|
||||
}
|
||||
|
||||
.precise-pdf-page canvas,
|
||||
.precise-pdf-page .textLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.precise-pdf-page canvas {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
opacity: 0;
|
||||
transition: opacity 80ms ease-out;
|
||||
}
|
||||
|
||||
.precise-pdf-page canvas.is-rendered {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.precise-pdf-page .textLayer {
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
color-scheme: only light;
|
||||
opacity: 1;
|
||||
line-height: 1;
|
||||
letter-spacing: normal;
|
||||
word-spacing: normal;
|
||||
text-size-adjust: none;
|
||||
forced-color-adjust: none;
|
||||
transform-origin: 0 0;
|
||||
caret-color: CanvasText;
|
||||
--min-font-size: 1;
|
||||
--text-scale-factor:
|
||||
calc(var(--total-scale-factor) * var(--min-font-size));
|
||||
--min-font-size-inv: calc(1 / var(--min-font-size));
|
||||
}
|
||||
|
||||
.precise-pdf-page .textLayer :is(span, br) {
|
||||
position: absolute;
|
||||
color: transparent;
|
||||
white-space: pre;
|
||||
cursor: text;
|
||||
user-select: text;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
.precise-pdf-page .textLayer > :not(.markedContent),
|
||||
.precise-pdf-page
|
||||
.textLayer
|
||||
.markedContent
|
||||
span:not(.markedContent) {
|
||||
z-index: 1;
|
||||
font-size:
|
||||
calc(var(--text-scale-factor) * var(--font-height, 0));
|
||||
transform:
|
||||
rotate(var(--rotate, 0deg))
|
||||
scaleX(var(--scale-x, 1))
|
||||
scale(var(--min-font-size-inv));
|
||||
}
|
||||
|
||||
.precise-pdf-page .textLayer .markedContent {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.precise-pdf-page .textLayer span[role="img"] {
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.precise-pdf-page .textLayer ::selection {
|
||||
color: transparent;
|
||||
background: rgb(0 100 255 / 25%);
|
||||
}
|
||||
|
||||
.precise-pdf-page .textLayer br::selection {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.precise-pdf-page .textLayer .endOfContent {
|
||||
position: absolute;
|
||||
display: block;
|
||||
z-index: 0;
|
||||
inset: 100% 0 0;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.precise-pdf-page .textLayer.selecting .endOfContent {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.precise-pdf-page-placeholder,
|
||||
.precise-pdf-page-error {
|
||||
position: absolute;
|
||||
display: grid;
|
||||
padding: 20px;
|
||||
inset: 0;
|
||||
place-items: center;
|
||||
color: #7a8580;
|
||||
background: #fff;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.precise-pdf-page-error {
|
||||
z-index: 2;
|
||||
color: #a23d3d;
|
||||
background: rgb(255 255 255 / 92%);
|
||||
}
|
||||
|
||||
.precise-pdf-status {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
gap: 14px;
|
||||
min-height: 500px;
|
||||
place-items: center;
|
||||
color: #6f7d76;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.precise-pdf-status.is-error {
|
||||
color: #a23d3d;
|
||||
}
|
||||
|
||||
.settings-layer {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
@@ -607,6 +771,10 @@ textarea:focus {
|
||||
max-width: 62%;
|
||||
}
|
||||
|
||||
.preview-heading-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.theme-control select {
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createSvgDataUrl,
|
||||
getLockedMermaidBoxSize,
|
||||
getMermaidImageAlt,
|
||||
resolveMermaidOutputMode
|
||||
} from "../src/mermaid-static-image";
|
||||
|
||||
describe("Mermaid 静态 SVG 图片", () => {
|
||||
it("生成无需外部请求的 UTF-8 Data URL", () => {
|
||||
const url = createSvgDataUrl(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><text>中文 & A</text></svg>'
|
||||
);
|
||||
|
||||
expect(url).toMatch(/^data:image\/svg\+xml;charset=utf-8,/);
|
||||
expect(decodeURIComponent(url.split(",")[1] ?? "")).toContain(
|
||||
"<text>中文 & A</text>"
|
||||
);
|
||||
});
|
||||
|
||||
it("保留渲染后的精确小数尺寸", () => {
|
||||
expect(
|
||||
getLockedMermaidBoxSize({
|
||||
width: 742.375,
|
||||
height: 386.625
|
||||
})
|
||||
).toEqual({
|
||||
width: 742.375,
|
||||
height: 386.625
|
||||
});
|
||||
expect(
|
||||
getLockedMermaidBoxSize({
|
||||
width: 0,
|
||||
height: 386.625
|
||||
})
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("优先使用无障碍标签并回退到 SVG 标题", () => {
|
||||
expect(getMermaidImageAlt(" 数据链路 ", "备用标题")).toBe(
|
||||
"数据链路"
|
||||
);
|
||||
expect(getMermaidImageAlt(null, " 数据关系图 ")).toBe(
|
||||
"数据关系图"
|
||||
);
|
||||
expect(getMermaidImageAlt(" ", null)).toBe("Mermaid 图表");
|
||||
});
|
||||
|
||||
it("默认使用静态 SVG 并允许内部切回内联模式", () => {
|
||||
expect(resolveMermaidOutputMode("")).toBe("svg-image");
|
||||
expect(resolveMermaidOutputMode("?target=pdf")).toBe("svg-image");
|
||||
expect(
|
||||
resolveMermaidOutputMode(
|
||||
"?target=pdf&mermaid-output=inline-svg"
|
||||
)
|
||||
).toBe("inline-svg");
|
||||
expect(
|
||||
resolveMermaidOutputMode("?mermaid-output=unknown")
|
||||
).toBe("svg-image");
|
||||
});
|
||||
});
|
||||
@@ -43,6 +43,8 @@ describe("分页预览协议", () => {
|
||||
expect(css).toContain("overflow-x: hidden");
|
||||
expect(css).toContain("overflow-y: scroll");
|
||||
expect(css).toContain('html[data-render-target="pdf"]');
|
||||
expect(css).toContain("height: auto !important");
|
||||
expect(css).toContain("overflow: visible !important");
|
||||
expect(css).toContain("padding: 0");
|
||||
expect(css).toContain("break-after: page");
|
||||
expect(css).not.toContain("@media print");
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolvePagedRenderTarget } from "../src/paged-render-target";
|
||||
|
||||
describe("分页文档渲染目标", () => {
|
||||
it("默认使用网页预览目标", () => {
|
||||
expect(resolvePagedRenderTarget("")).toBe("preview");
|
||||
expect(resolvePagedRenderTarget("?target=unknown")).toBe("preview");
|
||||
});
|
||||
|
||||
it("识别 PDF 渲染目标", () => {
|
||||
expect(resolvePagedRenderTarget("?target=pdf")).toBe("pdf");
|
||||
expect(resolvePagedRenderTarget("?foo=1&target=pdf")).toBe("pdf");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { defaultExportConfig } from "@md-to-pdf/core";
|
||||
import {
|
||||
createPdfExportCacheKey,
|
||||
getCachedPdfExport,
|
||||
getOrRequestPdfExport,
|
||||
parseContentDispositionFileName,
|
||||
requestPdfExport,
|
||||
type CachedPdfExport,
|
||||
type PdfExportRequest
|
||||
} from "../src/pdf-export";
|
||||
|
||||
describe("PDF 导出客户端", () => {
|
||||
it("仅复用与当前文档和导出配置完全匹配的 PDF", () => {
|
||||
const request: PdfExportRequest = {
|
||||
markdown: "# 测试",
|
||||
fileName: "测试.md",
|
||||
language: "zh-CN",
|
||||
exportConfig: defaultExportConfig
|
||||
};
|
||||
const key = createPdfExportCacheKey(request);
|
||||
const result = {
|
||||
blob: new Blob(["%PDF-test"]),
|
||||
fileName: "测试.pdf",
|
||||
pageCount: 1,
|
||||
mermaidErrorCount: 0
|
||||
};
|
||||
const cache: CachedPdfExport = { key, result };
|
||||
|
||||
expect(getCachedPdfExport(cache, key)).toBe(result);
|
||||
expect(
|
||||
getCachedPdfExport(
|
||||
cache,
|
||||
createPdfExportCacheKey({
|
||||
...request,
|
||||
markdown: "# 已修改"
|
||||
})
|
||||
)
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
getCachedPdfExport(
|
||||
cache,
|
||||
createPdfExportCacheKey({
|
||||
...request,
|
||||
exportConfig: {
|
||||
...defaultExportConfig,
|
||||
paper: {
|
||||
...defaultExportConfig.paper,
|
||||
orientation: "landscape"
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("命中缓存时复用同一个 PDF Blob 且不重复请求", async () => {
|
||||
const request: PdfExportRequest = {
|
||||
markdown: "# 测试",
|
||||
fileName: "测试.md",
|
||||
language: "zh-CN",
|
||||
exportConfig: defaultExportConfig
|
||||
};
|
||||
const result = {
|
||||
blob: new Blob(["%PDF-cached"]),
|
||||
fileName: "测试.pdf",
|
||||
pageCount: 1,
|
||||
mermaidErrorCount: 0
|
||||
};
|
||||
const cache: CachedPdfExport = {
|
||||
key: createPdfExportCacheKey(request),
|
||||
result
|
||||
};
|
||||
const requester = vi.fn();
|
||||
|
||||
const resolved = await getOrRequestPdfExport(
|
||||
request,
|
||||
cache,
|
||||
requester
|
||||
);
|
||||
|
||||
expect(requester).not.toHaveBeenCalled();
|
||||
expect(resolved).toEqual(cache);
|
||||
expect(resolved.result.blob).toBe(result.blob);
|
||||
});
|
||||
|
||||
it("缓存失效时请求并保存当前 PDF", async () => {
|
||||
const request: PdfExportRequest = {
|
||||
markdown: "# 新内容",
|
||||
fileName: "测试.md",
|
||||
language: "zh-CN",
|
||||
exportConfig: defaultExportConfig
|
||||
};
|
||||
const result = {
|
||||
blob: new Blob(["%PDF-new"]),
|
||||
fileName: "测试.pdf",
|
||||
pageCount: 2,
|
||||
mermaidErrorCount: 0
|
||||
};
|
||||
const requester = vi.fn(async () => result);
|
||||
|
||||
const resolved = await getOrRequestPdfExport(
|
||||
request,
|
||||
undefined,
|
||||
requester
|
||||
);
|
||||
|
||||
expect(requester).toHaveBeenCalledOnce();
|
||||
expect(resolved.key).toBe(createPdfExportCacheKey(request));
|
||||
expect(resolved.result).toBe(result);
|
||||
});
|
||||
|
||||
it("优先解析 UTF-8 下载文件名", () => {
|
||||
expect(
|
||||
parseContentDispositionFileName(
|
||||
"attachment; filename=\"document.pdf\"; " +
|
||||
"filename*=UTF-8''%E6%B5%8B%E8%AF%95.pdf"
|
||||
)
|
||||
).toBe("测试.pdf");
|
||||
});
|
||||
|
||||
it("请求 PDF 并读取生成信息", async () => {
|
||||
const fetcher = vi.fn(async () => {
|
||||
return new Response(new Blob(["%PDF-test"]), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/pdf",
|
||||
"content-disposition":
|
||||
"attachment; filename=\"document.pdf\"",
|
||||
"x-pdf-page-count": "4",
|
||||
"x-mermaid-error-count": "1"
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const result = await requestPdfExport(
|
||||
{
|
||||
markdown: "# 测试",
|
||||
fileName: "测试.md",
|
||||
language: "zh-CN",
|
||||
exportConfig: defaultExportConfig
|
||||
},
|
||||
fetcher
|
||||
);
|
||||
|
||||
expect(fetcher).toHaveBeenCalledWith(
|
||||
"/api/pdf",
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
expect(result.fileName).toBe("document.pdf");
|
||||
expect(result.pageCount).toBe(4);
|
||||
expect(result.mermaidErrorCount).toBe(1);
|
||||
expect(await result.blob.text()).toBe("%PDF-test");
|
||||
});
|
||||
|
||||
it("显示服务端返回的错误信息", async () => {
|
||||
const fetcher = vi.fn(async () => {
|
||||
return Response.json(
|
||||
{ message: "PDF 生成队列已满" },
|
||||
{ status: 503 }
|
||||
);
|
||||
});
|
||||
|
||||
await expect(
|
||||
requestPdfExport(
|
||||
{
|
||||
markdown: "# 测试",
|
||||
fileName: "测试.md",
|
||||
language: "zh-CN",
|
||||
exportConfig: defaultExportConfig
|
||||
},
|
||||
fetcher
|
||||
)
|
||||
).rejects.toThrow("PDF 生成队列已满");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getPdfCanvasRenderScale,
|
||||
getPdfPageCssDimensions,
|
||||
PDF_PAGE_CSS_SCALE
|
||||
} from "../src/pdf-page-geometry";
|
||||
|
||||
describe("精确 PDF 页面尺寸", () => {
|
||||
it("按 96 CSS PPI 映射 PDF 点尺寸", () => {
|
||||
expect(PDF_PAGE_CSS_SCALE).toBeCloseTo(4 / 3);
|
||||
expect(
|
||||
getPdfPageCssDimensions(595.275591, 841.889764)
|
||||
).toEqual({
|
||||
width: expect.closeTo(793.700788, 5),
|
||||
height: expect.closeTo(1122.519685, 5)
|
||||
});
|
||||
});
|
||||
|
||||
it("结合显示宽度和设备像素比计算 Canvas 比例", () => {
|
||||
expect(getPdfCanvasRenderScale(600, 800, 2)).toBeCloseTo(
|
||||
8 / 3
|
||||
);
|
||||
expect(getPdfCanvasRenderScale(0, 800, 2)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PdfPageRenderQueue } from "../src/pdf-render-queue";
|
||||
|
||||
async function flushPromises() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
describe("PdfPageRenderQueue", () => {
|
||||
it("限制并发并优先渲染可见页", async () => {
|
||||
const queue = new PdfPageRenderQueue(1);
|
||||
const order: number[] = [];
|
||||
let releaseFirst: (() => void) | undefined;
|
||||
|
||||
queue.schedule(
|
||||
1,
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
order.push(1);
|
||||
releaseFirst = resolve;
|
||||
})
|
||||
);
|
||||
queue.schedule(2, async () => {
|
||||
order.push(2);
|
||||
});
|
||||
queue.schedule(8, async () => {
|
||||
order.push(8);
|
||||
});
|
||||
queue.setVisible(8, true);
|
||||
|
||||
expect(order).toEqual([1]);
|
||||
releaseFirst?.();
|
||||
await flushPromises();
|
||||
expect(order).toEqual([1, 8, 2]);
|
||||
});
|
||||
|
||||
it("优先预渲染滚动方向上的相邻页面", async () => {
|
||||
const queue = new PdfPageRenderQueue(1);
|
||||
const order: number[] = [];
|
||||
let releaseFirst: (() => void) | undefined;
|
||||
|
||||
queue.schedule(
|
||||
5,
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
order.push(5);
|
||||
releaseFirst = resolve;
|
||||
})
|
||||
);
|
||||
queue.setVisible(5, true);
|
||||
queue.setVisible(5, false);
|
||||
queue.setVisible(6, true);
|
||||
queue.schedule(4, async () => {
|
||||
order.push(4);
|
||||
});
|
||||
queue.schedule(7, async () => {
|
||||
order.push(7);
|
||||
});
|
||||
|
||||
releaseFirst?.();
|
||||
await flushPromises();
|
||||
expect(order).toEqual([5, 7, 4]);
|
||||
});
|
||||
|
||||
it("取消尚未开始的任务", async () => {
|
||||
const queue = new PdfPageRenderQueue(1);
|
||||
let releaseFirst: (() => void) | undefined;
|
||||
const cancelledTask = vi.fn();
|
||||
|
||||
queue.schedule(
|
||||
1,
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve;
|
||||
})
|
||||
);
|
||||
const cancel = queue.schedule(2, async () => {
|
||||
cancelledTask();
|
||||
});
|
||||
cancel();
|
||||
|
||||
releaseFirst?.();
|
||||
await flushPromises();
|
||||
expect(cancelledTask).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user