feat: 实现 Chromium PDF 导出与精确预览
This commit is contained in:
@@ -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