feat: 完成 DOCX 视觉门禁与字体兼容映射
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createChromiumPdfAdapter,
|
||||
createWordPdfAdapter,
|
||||
createWpsPdfAdapter,
|
||||
preparePdfAdapterRun,
|
||||
runPdfAdapterVisualDiff,
|
||||
type OfficeAutomationBackend,
|
||||
} from "../src/index.js";
|
||||
import { createMinimalPdf } from "./pdf-fixture.js";
|
||||
|
||||
describe("PDF 产物适配器", () => {
|
||||
it("Chromium 适配器复用调用方生成链并校验 PDF", async () => {
|
||||
const generate = vi.fn(async () => ({
|
||||
pdf: createMinimalPdf("Chromium"),
|
||||
pageCount: 1,
|
||||
diagnostics: ["ok"],
|
||||
}));
|
||||
const adapter = createChromiumPdfAdapter({
|
||||
label: "统一 Chromium",
|
||||
generate,
|
||||
});
|
||||
expect(await adapter.probe()).toMatchObject({
|
||||
available: true,
|
||||
adapterId: "chromium",
|
||||
});
|
||||
const result = await adapter.generate({ markdown: "# 标题" });
|
||||
expect(generate).toHaveBeenCalledWith({ markdown: "# 标题" });
|
||||
expect(result.source.kind).toBe("chromium");
|
||||
expect(result.pageCount).toBe(1);
|
||||
expect(result.diagnostics).toEqual(["ok"]);
|
||||
});
|
||||
|
||||
it("拒绝生产者返回非 PDF 字节", async () => {
|
||||
const adapter = createChromiumPdfAdapter({
|
||||
generate: async () => new TextEncoder().encode("not a pdf"),
|
||||
});
|
||||
await expect(adapter.generate(undefined)).rejects.toThrow(
|
||||
"PDF 文件签名",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Office PDF 适配器", () => {
|
||||
it("分别使用 Word 与 WPS ProgID 并清理临时输出", async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), "visual-diff-office-test-"),
|
||||
);
|
||||
const docxPath = join(directory, "fixture.docx");
|
||||
await writeFile(docxPath, "fixture");
|
||||
const calls: Array<{
|
||||
client: string;
|
||||
progId: string;
|
||||
outputPath: string;
|
||||
}> = [];
|
||||
const backend: OfficeAutomationBackend = {
|
||||
probe: async () => true,
|
||||
exportPdf: async (options) => {
|
||||
const pdf = createMinimalPdf(options.client);
|
||||
await writeFile(options.outputPath, pdf);
|
||||
calls.push({
|
||||
client: options.client,
|
||||
progId: options.progId,
|
||||
outputPath: options.outputPath,
|
||||
});
|
||||
return { bytes: pdf.byteLength, pageCount: 1 };
|
||||
},
|
||||
};
|
||||
try {
|
||||
const word = createWordPdfAdapter({ backend });
|
||||
const wps = createWpsPdfAdapter({ backend });
|
||||
expect((await word.probe()).available).toBe(true);
|
||||
expect((await wps.probe()).available).toBe(true);
|
||||
expect((await word.generate({ docxPath })).source.kind).toBe("word");
|
||||
expect((await wps.generate({ docxPath })).source.kind).toBe("wps");
|
||||
expect(calls.map(({ client, progId }) => ({ client, progId }))).toEqual([
|
||||
{ client: "word", progId: "Word.Application" },
|
||||
{ client: "wps", progId: "KWPS.Application" },
|
||||
]);
|
||||
for (const call of calls) {
|
||||
await expect(
|
||||
import("node:fs/promises").then(({ stat }) =>
|
||||
stat(call.outputPath),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
}
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("PDF 适配器视觉编排", () => {
|
||||
it("基线只生成一次并按顺序比较全部候选", async () => {
|
||||
const order: string[] = [];
|
||||
const adapter = (label: string) =>
|
||||
createChromiumPdfAdapter({
|
||||
label,
|
||||
generate: async () => {
|
||||
order.push(label);
|
||||
return createMinimalPdf("same");
|
||||
},
|
||||
});
|
||||
const result = await runPdfAdapterVisualDiff({
|
||||
baseline: preparePdfAdapterRun(adapter("baseline"), undefined),
|
||||
candidates: [
|
||||
preparePdfAdapterRun(adapter("candidate-1"), undefined),
|
||||
preparePdfAdapterRun(adapter("candidate-2"), undefined),
|
||||
],
|
||||
snapshot: { includeRaster: false },
|
||||
});
|
||||
expect(order).toEqual(["baseline", "candidate-1", "candidate-2"]);
|
||||
expect(result.candidates).toHaveLength(2);
|
||||
expect(
|
||||
result.candidates.map(({ report }) => report.status),
|
||||
).toEqual(["failed", "failed"]);
|
||||
expect(
|
||||
result.candidates.flatMap(({ report }) =>
|
||||
report.issues.map((issue) => issue.code),
|
||||
),
|
||||
).toEqual(["RASTER_MISSING", "RASTER_MISSING"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
comparePdfSnapshotsBasic,
|
||||
createPdfDocumentSnapshot,
|
||||
type PdfDocumentSnapshot,
|
||||
} from "../src/index.js";
|
||||
import { createMinimalPdf } from "./pdf-fixture.js";
|
||||
|
||||
async function snapshot(
|
||||
label: string,
|
||||
text = "Visual diff fixture",
|
||||
width = 595,
|
||||
height = 842,
|
||||
): Promise<PdfDocumentSnapshot> {
|
||||
return createPdfDocumentSnapshot(createMinimalPdf(text, width, height), {
|
||||
source: { kind: "custom", label },
|
||||
includeRaster: false,
|
||||
});
|
||||
}
|
||||
|
||||
describe("PDF 基础视觉门禁", () => {
|
||||
it("通过纸张和正文一致的文档", async () => {
|
||||
const baseline = await snapshot("baseline");
|
||||
const candidate = await snapshot("candidate");
|
||||
const result = comparePdfSnapshotsBasic(baseline, candidate);
|
||||
expect(result.status).toBe("passed");
|
||||
expect(result.contentSimilarity).toBe(1);
|
||||
expect(result.issues).toEqual([]);
|
||||
});
|
||||
|
||||
it("拒绝纸张尺寸和正文内容偏差", async () => {
|
||||
const baseline = await snapshot("baseline");
|
||||
const candidate = await snapshot("candidate", "Different content", 612);
|
||||
const result = comparePdfSnapshotsBasic(baseline, candidate);
|
||||
expect(result.status).toBe("failed");
|
||||
expect(result.issues.map((issue) => issue.code)).toEqual(
|
||||
expect.arrayContaining(["PAGE_SIZE_MISMATCH", "CONTENT_MISMATCH"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("内容完整性比较不受重新换行影响", async () => {
|
||||
const baseline = await snapshot("baseline", "same content");
|
||||
const candidate = await snapshot("candidate", "samecontent");
|
||||
const result = comparePdfSnapshotsBasic(baseline, candidate);
|
||||
expect(result.contentSimilarity).toBe(1);
|
||||
expect(result.status).toBe("passed");
|
||||
});
|
||||
|
||||
it("页数不一致时仍保留溢出页配对记录", async () => {
|
||||
const baseline = await snapshot("baseline");
|
||||
const candidatePage = (await snapshot("candidate")).pages[0];
|
||||
if (!candidatePage) {
|
||||
throw new Error("测试快照缺少页面");
|
||||
}
|
||||
const candidate: PdfDocumentSnapshot = {
|
||||
...(await snapshot("candidate")),
|
||||
pageCount: 2,
|
||||
pages: [
|
||||
candidatePage,
|
||||
{
|
||||
...candidatePage,
|
||||
pageNumber: 2,
|
||||
contentText: "",
|
||||
items: [],
|
||||
lines: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = comparePdfSnapshotsBasic(baseline, candidate);
|
||||
expect(result.pagePairs[1]).toEqual({
|
||||
candidatePageNumber: 2,
|
||||
status: "candidate-only",
|
||||
});
|
||||
expect(result.issues.map((issue) => issue.code)).toContain(
|
||||
"UNPAIRED_CANDIDATE_PAGE",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
export function createMinimalPdf(
|
||||
text = "Visual diff fixture",
|
||||
pageWidth = 595,
|
||||
pageHeight = 842,
|
||||
): Uint8Array {
|
||||
const escapedText = text.replaceAll("\\", "\\\\").replaceAll("(", "\\(").replaceAll(")", "\\)");
|
||||
const content = `BT /F1 18 Tf 72 ${pageHeight - 72} Td (${escapedText}) Tj ET`;
|
||||
const objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageWidth} ${pageHeight}] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>`,
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
`<< /Length ${content.length} >>\nstream\n${content}\nendstream`,
|
||||
];
|
||||
let pdf = "%PDF-1.4\n";
|
||||
const offsets = [0];
|
||||
for (const [index, object] of objects.entries()) {
|
||||
offsets.push(pdf.length);
|
||||
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
|
||||
}
|
||||
const xrefOffset = pdf.length;
|
||||
pdf += `xref\n0 ${objects.length + 1}\n`;
|
||||
pdf += "0000000000 65535 f \n";
|
||||
for (const offset of offsets.slice(1)) {
|
||||
pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
|
||||
}
|
||||
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\n`;
|
||||
pdf += `startxref\n${xrefOffset}\n%%EOF\n`;
|
||||
return new TextEncoder().encode(pdf);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { createCanvas } from "@napi-rs/canvas";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
comparePageRasters,
|
||||
type PdfPageRaster,
|
||||
} from "../src/index.js";
|
||||
|
||||
function raster(rectangleX: number): PdfPageRaster {
|
||||
const canvas = createCanvas(80, 100);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, 80, 100);
|
||||
context.fillStyle = "#111111";
|
||||
context.fillRect(rectangleX, 20, 20, 40);
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx: 80,
|
||||
heightPx: 100,
|
||||
dpi: 144,
|
||||
sha256: createHash("sha256").update(png).digest("hex"),
|
||||
png,
|
||||
};
|
||||
}
|
||||
|
||||
describe("PDF 栅格差异", () => {
|
||||
it("相同页面的全部指标为无差异", async () => {
|
||||
const page = raster(10);
|
||||
const result = await comparePageRasters(page, page);
|
||||
expect(result.metrics).toMatchObject({
|
||||
dimensionsMatch: true,
|
||||
meanAbsoluteError: 0,
|
||||
changedPixelRatio: 0,
|
||||
inkIou: 1,
|
||||
edgeIou: 1,
|
||||
});
|
||||
expect(result.artifacts.overlayPng.subarray(0, 8)).toEqual(
|
||||
new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||
);
|
||||
});
|
||||
|
||||
it("量化位移并生成稳定叠加图与热力图", async () => {
|
||||
const result = await comparePageRasters(raster(10), raster(20));
|
||||
expect(result.metrics.meanAbsoluteError).toBeGreaterThan(10);
|
||||
expect(result.metrics.changedPixelRatio).toBeCloseTo(0.1, 2);
|
||||
expect(result.metrics.inkIou).toBeCloseTo(1 / 3, 2);
|
||||
expect(result.metrics.edgeIou).toBeLessThan(0.4);
|
||||
expect(result.artifacts.overlaySha256).toHaveLength(64);
|
||||
expect(result.artifacts.heatmapSha256).toHaveLength(64);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { createCanvas } from "@napi-rs/canvas";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createPdfVisualDiffReport,
|
||||
renderPdfVisualDiffHtml,
|
||||
serializePdfVisualDiffJson,
|
||||
type PdfDocumentSnapshot,
|
||||
type PdfPageRaster,
|
||||
} from "../src/index.js";
|
||||
|
||||
function raster(color: string): PdfPageRaster {
|
||||
const canvas = createCanvas(40, 50);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, 40, 50);
|
||||
context.fillStyle = color;
|
||||
context.fillRect(10, 10, 20, 20);
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx: 40,
|
||||
heightPx: 50,
|
||||
dpi: 144,
|
||||
sha256: createHash("sha256").update(png).digest("hex"),
|
||||
png,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(label: string, pageRaster: PdfPageRaster): PdfDocumentSnapshot {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
source: { kind: "custom", label },
|
||||
sha256: "a".repeat(64),
|
||||
pageCount: 1,
|
||||
contentText: "相同正文",
|
||||
pages: [
|
||||
{
|
||||
pageNumber: 1,
|
||||
widthPt: 20,
|
||||
heightPt: 25,
|
||||
rotation: 0,
|
||||
items: [],
|
||||
lines: [],
|
||||
contentText: "相同正文",
|
||||
raster: pageRaster,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("PDF 视觉差异报告", () => {
|
||||
it("汇总栅格门禁并生成内嵌图片的安全 HTML", async () => {
|
||||
const report = await createPdfVisualDiffReport(
|
||||
snapshot("<基线>", raster("#111111")),
|
||||
snapshot("候选", raster("#cc0000")),
|
||||
{ generatedAt: "2026-07-31T00:00:00.000Z" },
|
||||
);
|
||||
const html = renderPdfVisualDiffHtml(report);
|
||||
expect(report.status).toBe("failed");
|
||||
expect(html).toContain("<基线>");
|
||||
expect(html).toContain("data:image/png;base64,");
|
||||
expect(html).toContain("差异热力图");
|
||||
expect(html).not.toContain("<基线>");
|
||||
});
|
||||
|
||||
it("JSON 只保留图片字节数和稳定摘要", async () => {
|
||||
const page = raster("#111111");
|
||||
const report = await createPdfVisualDiffReport(
|
||||
snapshot("基线", page),
|
||||
snapshot("候选", page),
|
||||
);
|
||||
const json = serializePdfVisualDiffJson(report);
|
||||
expect(json).toContain('"overlaySha256"');
|
||||
expect(json).toContain('"byteLength"');
|
||||
expect(json).not.toContain('"0": 137');
|
||||
});
|
||||
|
||||
it("在报告中展示未配对的溢出页面", async () => {
|
||||
const baseline = snapshot("基线", raster("#111111"));
|
||||
const candidatePage = snapshot("候选", raster("#111111"));
|
||||
const candidate: PdfDocumentSnapshot = {
|
||||
...candidatePage,
|
||||
pageCount: 2,
|
||||
pages: [
|
||||
candidatePage.pages[0]!,
|
||||
{
|
||||
...candidatePage.pages[0]!,
|
||||
pageNumber: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate);
|
||||
const overflow = report.pages[1];
|
||||
expect(overflow?.pair.status).toBe("candidate-only");
|
||||
expect(overflow?.unpairedPng?.byteLength).toBeGreaterThan(0);
|
||||
expect(renderPdfVisualDiffHtml(report)).toContain("未配对页面");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createPdfDocumentSnapshot } from "../src/index.js";
|
||||
import { createMinimalPdf } from "./pdf-fixture.js";
|
||||
|
||||
describe("PDF.js 页面快照", () => {
|
||||
it("从真实 PDF 同时提取纸张、文本和稳定 PNG", async () => {
|
||||
const pdf = createMinimalPdf();
|
||||
const first = await createPdfDocumentSnapshot(pdf, {
|
||||
source: { kind: "custom", label: "fixture" },
|
||||
dpi: 144,
|
||||
});
|
||||
const second = await createPdfDocumentSnapshot(pdf, {
|
||||
source: { kind: "custom", label: "fixture" },
|
||||
dpi: 144,
|
||||
});
|
||||
|
||||
expect(first.pageCount).toBe(1);
|
||||
expect(first.pages[0]?.widthPt).toBeCloseTo(595, 3);
|
||||
expect(first.pages[0]?.heightPt).toBeCloseTo(842, 3);
|
||||
expect(first.contentText).toContain("Visual diff fixture");
|
||||
expect(first.pages[0]?.raster).toMatchObject({
|
||||
widthPx: 1190,
|
||||
heightPx: 1684,
|
||||
dpi: 144,
|
||||
});
|
||||
expect(first.pages[0]?.raster?.png.byteLength).toBeGreaterThan(1000);
|
||||
expect(first.pages[0]?.raster?.sha256).toBe(
|
||||
second.pages[0]?.raster?.sha256,
|
||||
);
|
||||
});
|
||||
|
||||
it("允许关闭栅格输出以执行轻量结构门禁", async () => {
|
||||
const snapshot = await createPdfDocumentSnapshot(createMinimalPdf(), {
|
||||
source: { kind: "custom", label: "fixture" },
|
||||
includeRaster: false,
|
||||
});
|
||||
expect(snapshot.pages[0]?.raster).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
aggregatePdfTextLines,
|
||||
buildPageContentText,
|
||||
normalizePdfText,
|
||||
type PdfTextItemSnapshot,
|
||||
} from "../src/index.js";
|
||||
|
||||
function item(
|
||||
text: string,
|
||||
x: number,
|
||||
y: number,
|
||||
width = 20,
|
||||
height = 10,
|
||||
): PdfTextItemSnapshot {
|
||||
return {
|
||||
text,
|
||||
normalizedText: normalizePdfText(text),
|
||||
bounds: { x, y, width, height },
|
||||
baselineY: y + height,
|
||||
hasEol: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("PDF 文本行聚合", () => {
|
||||
it("按坐标聚合中文文本并规范化兼容字符", () => {
|
||||
const lines = aggregatePdfTextLines(
|
||||
[
|
||||
item("ABC", 10, 100, 30),
|
||||
item("中文", 41, 100, 20),
|
||||
item("第二行", 10, 120, 40),
|
||||
],
|
||||
842,
|
||||
);
|
||||
expect(lines.map((line) => line.normalizedText)).toEqual([
|
||||
"ABC中文",
|
||||
"第二行",
|
||||
]);
|
||||
});
|
||||
|
||||
it("只在页眉页脚坐标带识别独立页码", () => {
|
||||
const lines = aggregatePdfTextLines(
|
||||
[
|
||||
item("章节 1", 72, 400, 50),
|
||||
item("— 1 —", 280, 731, 35, 14),
|
||||
],
|
||||
842,
|
||||
);
|
||||
expect(lines.map((line) => line.role)).toEqual([
|
||||
"content",
|
||||
"page-number",
|
||||
]);
|
||||
expect(buildPageContentText(lines)).toBe("章节 1");
|
||||
});
|
||||
|
||||
it("不会把页脚中的普通说明误判为页码", () => {
|
||||
const lines = aggregatePdfTextLines(
|
||||
[item("内部资料 1", 72, 800, 70)],
|
||||
842,
|
||||
);
|
||||
expect(lines[0]?.role).toBe("content");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user