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
@@ -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>中文 &amp; A</text></svg>'
);
expect(url).toMatch(/^data:image\/svg\+xml;charset=utf-8,/);
expect(decodeURIComponent(url.split(",")[1] ?? "")).toContain(
"<text>中文 &amp; 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");
});
});
+2
View File
@@ -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");
});
});
+176
View File
@@ -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 生成队列已满");
});
});
+25
View File
@@ -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);
});
});
+85
View File
@@ -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();
});
});