feat: 实现 Chromium PDF 导出与精确预览

This commit is contained in:
SkyJourney
2026-07-26 13:21:17 +08:00
parent d62552384f
commit d1b5880a5b
26 changed files with 3328 additions and 316 deletions
+134
View File
@@ -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);
}