feat: 完成 DOCX R4 元素级视觉门禁
建立 Chromium、Word 与 WPS 的可编辑内容、段落几何、页眉页脚、封面、媒体和逐页视觉比较链路。 支持封面独立分节、正文页码重启、主题页边距与横向纸张,并将自然分页差异降为诊断项。 修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。
This commit is contained in:
@@ -6,7 +6,9 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
createChromiumPdfAdapter,
|
||||
createWordDocxRoundTripAdapter,
|
||||
createWordPdfAdapter,
|
||||
createWpsDocxRoundTripAdapter,
|
||||
createWpsPdfAdapter,
|
||||
preparePdfAdapterRun,
|
||||
runPdfAdapterVisualDiff,
|
||||
@@ -58,6 +60,7 @@ describe("Office PDF 适配器", () => {
|
||||
progId: string;
|
||||
outputPath: string;
|
||||
}> = [];
|
||||
const roundTripCalls: string[] = [];
|
||||
const backend: OfficeAutomationBackend = {
|
||||
probe: async () => true,
|
||||
exportPdf: async (options) => {
|
||||
@@ -70,6 +73,22 @@ describe("Office PDF 适配器", () => {
|
||||
});
|
||||
return { bytes: pdf.byteLength, pageCount: 1 };
|
||||
},
|
||||
roundTripDocx: async (options) => {
|
||||
const docx = Buffer.from("round-trip-docx");
|
||||
await writeFile(options.outputPath, docx);
|
||||
roundTripCalls.push(options.client);
|
||||
return {
|
||||
bytes: docx.byteLength,
|
||||
before: [
|
||||
{ index: 1, widthPt: 100, heightPt: 50, type: 3 },
|
||||
],
|
||||
saved: [
|
||||
{ index: 1, widthPt: 90, heightPt: 45, type: 3 },
|
||||
],
|
||||
resizedShapeIndex: 1,
|
||||
resizeScale: options.resizeScale,
|
||||
};
|
||||
},
|
||||
};
|
||||
try {
|
||||
const word = createWordPdfAdapter({ backend });
|
||||
@@ -78,6 +97,15 @@ describe("Office PDF 适配器", () => {
|
||||
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");
|
||||
const wordRoundTrip = await createWordDocxRoundTripAdapter({
|
||||
backend,
|
||||
}).generate({ docxPath });
|
||||
const wpsRoundTrip = await createWpsDocxRoundTripAdapter({
|
||||
backend,
|
||||
}).generate({ docxPath });
|
||||
expect(wordRoundTrip.saved[0]?.widthPt).toBe(90);
|
||||
expect(wpsRoundTrip.saved[0]?.heightPt).toBe(45);
|
||||
expect(roundTripCalls).toEqual(["word", "wps"]);
|
||||
expect(calls.map(({ client, progId }) => ({ client, progId }))).toEqual([
|
||||
{ client: "word", progId: "Word.Application" },
|
||||
{ client: "wps", progId: "KWPS.Application" },
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
calculateContentSimilarity,
|
||||
comparePdfSnapshotsBasic,
|
||||
createPdfDocumentSnapshot,
|
||||
type PdfDocumentSnapshot,
|
||||
type PdfTextLineSnapshot,
|
||||
type VisualPageSemanticExpectation,
|
||||
} from "../src/index.js";
|
||||
import { createMinimalPdf } from "./pdf-fixture.js";
|
||||
|
||||
@@ -19,7 +22,105 @@ async function snapshot(
|
||||
});
|
||||
}
|
||||
|
||||
function textLine(
|
||||
text: string,
|
||||
y: number,
|
||||
role: PdfTextLineSnapshot["role"] = "content",
|
||||
): PdfTextLineSnapshot {
|
||||
return {
|
||||
text,
|
||||
normalizedText: text,
|
||||
bounds: { x: 72, y, width: 200, height: 12 },
|
||||
baselineY: y + 10,
|
||||
role,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
function withLines(
|
||||
source: PdfDocumentSnapshot,
|
||||
lines: PdfTextLineSnapshot[],
|
||||
): PdfDocumentSnapshot {
|
||||
const page = source.pages[0];
|
||||
if (!page) {
|
||||
throw new Error("测试快照缺少页面");
|
||||
}
|
||||
return {
|
||||
...source,
|
||||
contentText: lines.map((line) => line.normalizedText).join("\n"),
|
||||
pages: [{ ...page, lines }],
|
||||
};
|
||||
}
|
||||
|
||||
const pageSemantics: VisualPageSemanticExpectation[] = [
|
||||
{
|
||||
physicalPageNumber: 1,
|
||||
kind: "body-first",
|
||||
logicalPageNumber: 1,
|
||||
logicalPageCount: 1,
|
||||
headerVisible: true,
|
||||
headerSlots: [{ alignment: "left", text: "页眉左栏" }],
|
||||
footerVisible: true,
|
||||
footerAlignment: "center",
|
||||
pageNumberText: "1 / 1",
|
||||
},
|
||||
];
|
||||
|
||||
describe("PDF 基础视觉门禁", () => {
|
||||
it("将 PDF 字体映射产生的等价部首字形规范为正文汉字", () => {
|
||||
expect(
|
||||
calculateContentSimilarity(
|
||||
"示例市人⺠政府办公室",
|
||||
"示例市人民政府办公室",
|
||||
),
|
||||
).toBe(1);
|
||||
expect(calculateContentSimilarity("项目⻔户", "项目门户")).toBe(1);
|
||||
});
|
||||
|
||||
it("以 DOCX 可编辑正文为语义基准并容许 Chromium 媒体内部文本", async () => {
|
||||
const baseline = withLines(await snapshot("baseline"), [
|
||||
textLine("页眉左栏", 10),
|
||||
textLine("正文甲", 100),
|
||||
textLine("媒体内部标签", 150),
|
||||
textLine("正文乙", 200),
|
||||
textLine("1 / 1", 820, "page-number"),
|
||||
]);
|
||||
const candidate = withLines(await snapshot("candidate"), [
|
||||
textLine("页眉左栏", 10),
|
||||
textLine("正文甲☒", 100),
|
||||
textLine("正文乙", 200),
|
||||
textLine("1 / 1", 820, "page-number"),
|
||||
]);
|
||||
const result = comparePdfSnapshotsBasic(baseline, candidate, {}, {
|
||||
expectedEditableText: "正文甲正文乙",
|
||||
pageSemantics,
|
||||
});
|
||||
expect(result.status).toBe("passed");
|
||||
expect(result.baselineEditableCoverage).toBe(1);
|
||||
expect(result.candidateEditableSimilarity).toBe(1);
|
||||
expect(result.candidateEditableExact).toBe(true);
|
||||
});
|
||||
|
||||
it("候选缺少任一可编辑正文字符时严格失败", async () => {
|
||||
const baseline = withLines(await snapshot("baseline"), [
|
||||
textLine("正文甲正文乙", 100),
|
||||
]);
|
||||
const candidate = withLines(await snapshot("candidate"), [
|
||||
textLine("正文甲正文", 100),
|
||||
]);
|
||||
const result = comparePdfSnapshotsBasic(baseline, candidate, {}, {
|
||||
expectedEditableText: "正文甲正文乙",
|
||||
});
|
||||
expect(result.status).toBe("failed");
|
||||
expect(result.baselineEditableCoverage).toBe(1);
|
||||
expect(result.candidateEditableExact).toBe(false);
|
||||
expect(result.issues).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: "CONTENT_MISMATCH" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("通过纸张和正文一致的文档", async () => {
|
||||
const baseline = await snapshot("baseline");
|
||||
const candidate = await snapshot("candidate");
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
comparePdfEditableParagraphLayouts,
|
||||
type EditableParagraphExpectation,
|
||||
type PdfDocumentSnapshot,
|
||||
type PdfTextLineSnapshot,
|
||||
} from "../src/index.js";
|
||||
|
||||
function line(
|
||||
text: string,
|
||||
y: number,
|
||||
width = 120,
|
||||
x = 72,
|
||||
): PdfTextLineSnapshot {
|
||||
return {
|
||||
text,
|
||||
normalizedText: text,
|
||||
bounds: { x, y: y - 10, width, height: 12 },
|
||||
baselineY: y,
|
||||
role: "content",
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(
|
||||
label: string,
|
||||
pages: PdfTextLineSnapshot[][],
|
||||
): PdfDocumentSnapshot {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
source: { kind: "custom", label },
|
||||
sha256: label.padEnd(64, "0").slice(0, 64),
|
||||
pageCount: pages.length,
|
||||
contentText: pages.flat().map((item) => item.normalizedText).join(""),
|
||||
pages: pages.map((lines, index) => ({
|
||||
pageNumber: index + 1,
|
||||
widthPt: 595,
|
||||
heightPt: 842,
|
||||
rotation: 0,
|
||||
items: [],
|
||||
lines,
|
||||
contentText: lines.map((item) => item.normalizedText).join(""),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function expectation(
|
||||
text: string,
|
||||
overrides: Partial<EditableParagraphExpectation> = {},
|
||||
): EditableParagraphExpectation {
|
||||
return {
|
||||
index: 0,
|
||||
text,
|
||||
role: "body",
|
||||
section: "body",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("PDF 内容感知段落版式门禁", () => {
|
||||
it("允许正文段落整体移动到下一物理页", () => {
|
||||
const baseline = snapshot("baseline", [
|
||||
[line("建立月度调度机制及时协调", 700), line("解决项目中的问题", 728)],
|
||||
[],
|
||||
]);
|
||||
const candidate = snapshot("candidate", [
|
||||
[],
|
||||
[line("建立月度调度机制及时协调", 100), line("解决项目中的问题", 128)],
|
||||
]);
|
||||
const [result] = comparePdfEditableParagraphLayouts(
|
||||
baseline,
|
||||
candidate,
|
||||
[expectation("建立月度调度机制及时协调解决项目中的问题")],
|
||||
);
|
||||
expect(result?.status).toBe("passed");
|
||||
expect(result?.baseline.pageNumbers).toEqual([1]);
|
||||
expect(result?.candidate.pageNumbers).toEqual([2]);
|
||||
});
|
||||
|
||||
it("拒绝标题从一行变成两行", () => {
|
||||
const baseline = snapshot("baseline", [
|
||||
[line("智慧园区一体化平台建设项目", 220, 250)],
|
||||
]);
|
||||
const candidate = snapshot("candidate", [
|
||||
[line("智慧园区一体化平台", 220, 190), line("建设项目", 250, 60)],
|
||||
]);
|
||||
const [result] = comparePdfEditableParagraphLayouts(
|
||||
baseline,
|
||||
candidate,
|
||||
[
|
||||
expectation("智慧园区一体化平台建设项目", {
|
||||
role: "title",
|
||||
section: "cover",
|
||||
styleId: "MdProjectReportProjectName",
|
||||
}),
|
||||
],
|
||||
);
|
||||
expect(result?.status).toBe("failed");
|
||||
expect(result?.issues.map((issue) => issue.code)).toContain(
|
||||
"TEXT_BLOCK_LINE_COUNT_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("允许长正文仅有极短末行的跨引擎换行差异", () => {
|
||||
const text =
|
||||
"数字化管理部门负责项目统筹技术审查过程监督和验收管理需求单位负责业务需求确认试运行和应用推广";
|
||||
const baseline = snapshot("baseline", [
|
||||
[line(text.slice(0, -2), 220, 680), line(text.slice(-2), 244, 28)],
|
||||
]);
|
||||
const candidate = snapshot("candidate", [[line(text, 220, 695)]]);
|
||||
const [result] = comparePdfEditableParagraphLayouts(
|
||||
baseline,
|
||||
candidate,
|
||||
[expectation(text)],
|
||||
);
|
||||
expect(result?.status).toBe("warning");
|
||||
expect(result?.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
code: "BODY_LINE_COUNT_NEAR_BOUNDARY",
|
||||
severity: "warning",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("拒绝短正文或非极短末行的行数差异", () => {
|
||||
const text = "项目建设内容需要严格控制质量进度投资安全风险";
|
||||
const baseline = snapshot("baseline", [
|
||||
[line(text.slice(0, -5), 220), line(text.slice(-5), 244)],
|
||||
]);
|
||||
const candidate = snapshot("candidate", [[line(text, 220)]]);
|
||||
const [result] = comparePdfEditableParagraphLayouts(
|
||||
baseline,
|
||||
candidate,
|
||||
[expectation(text)],
|
||||
);
|
||||
expect(result?.status).toBe("failed");
|
||||
expect(result?.issues.map((issue) => issue.code)).toContain(
|
||||
"TEXT_BLOCK_LINE_COUNT_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("拒绝封面内容漂移到正文页", () => {
|
||||
const baseline = snapshot("baseline", [
|
||||
[line("可行性研究报告", 300, 180)],
|
||||
[],
|
||||
]);
|
||||
const candidate = snapshot("candidate", [
|
||||
[],
|
||||
[line("可行性研究报告", 100, 180)],
|
||||
]);
|
||||
const [result] = comparePdfEditableParagraphLayouts(
|
||||
baseline,
|
||||
candidate,
|
||||
[
|
||||
expectation("可行性研究报告", {
|
||||
role: "title",
|
||||
section: "cover",
|
||||
styleId: "MdProjectReportTitle",
|
||||
}),
|
||||
],
|
||||
);
|
||||
expect(result?.status).toBe("failed");
|
||||
expect(result?.issues.map((issue) => issue.code)).toContain(
|
||||
"COVER_LAYOUT_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("忽略 PDF 文本提取器失真的单行宽度但保留诊断值", () => {
|
||||
const baseline = snapshot("baseline", [
|
||||
[line("数据治理平台建设项目", 300, 224)],
|
||||
]);
|
||||
const candidate = snapshot("candidate", [
|
||||
[line("数据治理平台建设项目", 300, 210)],
|
||||
]);
|
||||
const [result] = comparePdfEditableParagraphLayouts(
|
||||
baseline,
|
||||
candidate,
|
||||
[
|
||||
expectation("数据治理平台建设项目", {
|
||||
role: "title",
|
||||
section: "cover",
|
||||
styleId: "MdProjectReportTitle",
|
||||
}),
|
||||
],
|
||||
);
|
||||
expect(result?.status).toBe("passed");
|
||||
expect(result?.baseline.maximumLineWidthPt).toBe(224);
|
||||
expect(result?.candidate.maximumLineWidthPt).toBe(210);
|
||||
});
|
||||
|
||||
it("允许封面字形基线在 3pt 内波动", () => {
|
||||
const baseline = snapshot("baseline", [
|
||||
[line("数据治理平台建设项目", 300, 224)],
|
||||
]);
|
||||
const candidate = snapshot("candidate", [
|
||||
[line("数据治理平台建设项目", 302.5, 224)],
|
||||
]);
|
||||
const [result] = comparePdfEditableParagraphLayouts(
|
||||
baseline,
|
||||
candidate,
|
||||
[
|
||||
expectation("数据治理平台建设项目", {
|
||||
role: "title",
|
||||
section: "cover",
|
||||
styleId: "MdProjectReportTitle",
|
||||
}),
|
||||
],
|
||||
);
|
||||
expect(result?.status).toBe("passed");
|
||||
});
|
||||
|
||||
it("拒绝封面字形基线偏移超过 6pt", () => {
|
||||
const baseline = snapshot("baseline", [
|
||||
[line("数据治理平台建设项目", 300, 224)],
|
||||
]);
|
||||
const candidate = snapshot("candidate", [
|
||||
[line("数据治理平台建设项目", 306.1, 224)],
|
||||
]);
|
||||
const [result] = comparePdfEditableParagraphLayouts(
|
||||
baseline,
|
||||
candidate,
|
||||
[
|
||||
expectation("数据治理平台建设项目", {
|
||||
role: "title",
|
||||
section: "cover",
|
||||
styleId: "MdProjectReportTitle",
|
||||
}),
|
||||
],
|
||||
);
|
||||
expect(result?.status).toBe("failed");
|
||||
expect(result?.issues.map((issue) => issue.code)).toContain(
|
||||
"COVER_LAYOUT_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("允许封面文本提取框横向偏差在 3pt 内波动", () => {
|
||||
const baseline = snapshot("baseline", [
|
||||
[line("数据治理平台建设项目", 300, 224, 198)],
|
||||
]);
|
||||
const candidate = snapshot("candidate", [
|
||||
[line("数据治理平台建设项目", 300, 224, 200.5)],
|
||||
]);
|
||||
const [result] = comparePdfEditableParagraphLayouts(
|
||||
baseline,
|
||||
candidate,
|
||||
[
|
||||
expectation("数据治理平台建设项目", {
|
||||
role: "title",
|
||||
section: "cover",
|
||||
styleId: "MdProjectReportTitle",
|
||||
}),
|
||||
],
|
||||
);
|
||||
expect(result?.status).toBe("passed");
|
||||
});
|
||||
|
||||
it("正文文本横向偏差仍严格限制为 2pt", () => {
|
||||
const baseline = snapshot("baseline", [
|
||||
[line("项目建设内容", 300, 120, 72)],
|
||||
]);
|
||||
const candidate = snapshot("candidate", [
|
||||
[line("项目建设内容", 300, 120, 74.5)],
|
||||
]);
|
||||
const [result] = comparePdfEditableParagraphLayouts(
|
||||
baseline,
|
||||
candidate,
|
||||
[expectation("项目建设内容")],
|
||||
);
|
||||
expect(result?.status).toBe("failed");
|
||||
expect(result?.issues.map((issue) => issue.code)).toContain(
|
||||
"PARAGRAPH_LINE_GEOMETRY_MISMATCH",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,23 @@ function raster(rectangleX: number): PdfPageRaster {
|
||||
};
|
||||
}
|
||||
|
||||
function differentlySizedRaster(width: number, height: number): PdfPageRaster {
|
||||
const canvas = createCanvas(width, height);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, width, height);
|
||||
context.fillStyle = "#111111";
|
||||
context.fillRect(width * 0.25, height * 0.2, width * 0.25, height * 0.4);
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx: width,
|
||||
heightPx: height,
|
||||
dpi: 144,
|
||||
sha256: createHash("sha256").update(png).digest("hex"),
|
||||
png,
|
||||
};
|
||||
}
|
||||
|
||||
describe("PDF 栅格差异", () => {
|
||||
it("相同页面的全部指标为无差异", async () => {
|
||||
const page = raster(10);
|
||||
@@ -50,4 +67,24 @@ describe("PDF 栅格差异", () => {
|
||||
expect(result.artifacts.overlaySha256).toHaveLength(64);
|
||||
expect(result.artifacts.heatmapSha256).toHaveLength(64);
|
||||
});
|
||||
|
||||
it("可按物理页面基线规范化一像素的栅格舍入差", async () => {
|
||||
const result = await comparePageRasters(
|
||||
differentlySizedRaster(80, 100),
|
||||
differentlySizedRaster(81, 101),
|
||||
{
|
||||
targetWidthPx: 80,
|
||||
targetHeightPx: 100,
|
||||
geometryNormalized: true,
|
||||
},
|
||||
);
|
||||
expect(result.metrics).toMatchObject({
|
||||
baselineWidthPx: 80,
|
||||
candidateWidthPx: 81,
|
||||
comparedWidthPx: 80,
|
||||
dimensionsMatch: false,
|
||||
geometryNormalized: true,
|
||||
});
|
||||
expect(result.metrics.meanAbsoluteError).toBeLessThan(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
serializePdfVisualDiffJson,
|
||||
type PdfDocumentSnapshot,
|
||||
type PdfPageRaster,
|
||||
type PdfTextLineSnapshot,
|
||||
type VisualPageSemanticExpectation,
|
||||
} from "../src/index.js";
|
||||
|
||||
function raster(color: string): PdfPageRaster {
|
||||
@@ -50,6 +52,57 @@ function snapshot(label: string, pageRaster: PdfPageRaster): PdfDocumentSnapshot
|
||||
};
|
||||
}
|
||||
|
||||
function contentLine(text: string, y: number): PdfTextLineSnapshot {
|
||||
return {
|
||||
text,
|
||||
normalizedText: text,
|
||||
bounds: { x: 4, y, width: 8, height: 2 },
|
||||
baselineY: y + 2,
|
||||
role: "content",
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
function flowSnapshot(
|
||||
label: string,
|
||||
pageLines: readonly (readonly PdfTextLineSnapshot[])[],
|
||||
color: string,
|
||||
): PdfDocumentSnapshot {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
source: { kind: "custom", label },
|
||||
sha256: label.padEnd(64, "0").slice(0, 64),
|
||||
pageCount: pageLines.length,
|
||||
contentText: pageLines.flat().map((line) => line.normalizedText).join(""),
|
||||
pages: pageLines.map((lines, index) => ({
|
||||
pageNumber: index + 1,
|
||||
widthPt: 20,
|
||||
heightPt: 25,
|
||||
rotation: 0,
|
||||
items: [],
|
||||
lines: [...lines],
|
||||
contentText: lines.map((line) => line.normalizedText).join(""),
|
||||
raster: raster(color),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function bodySemantics(pageCount: number): VisualPageSemanticExpectation[] {
|
||||
return Array.from({ length: pageCount }, (_, index) => ({
|
||||
physicalPageNumber: index + 1,
|
||||
kind: index === 0 ? "body-first" : "body-rest",
|
||||
logicalPageNumber: index + 1,
|
||||
logicalPageCount: pageCount,
|
||||
headerVisible: false,
|
||||
footerVisible: false,
|
||||
}));
|
||||
}
|
||||
|
||||
const flowParagraphs = [
|
||||
{ index: 0, text: "甲", role: "body" as const, section: "body" as const },
|
||||
{ index: 1, text: "乙丙", role: "body" as const, section: "body" as const },
|
||||
];
|
||||
|
||||
describe("PDF 视觉差异报告", () => {
|
||||
it("汇总栅格门禁并生成内嵌图片的安全 HTML", async () => {
|
||||
const report = await createPdfVisualDiffReport(
|
||||
@@ -97,4 +150,352 @@ describe("PDF 视觉差异报告", () => {
|
||||
expect(overflow?.unpairedPng?.byteLength).toBeGreaterThan(0);
|
||||
expect(renderPdfVisualDiffHtml(report)).toContain("未配对页面");
|
||||
});
|
||||
|
||||
it("纸张物理尺寸容差内不因一像素舍入差重复失败", async () => {
|
||||
const baseline = snapshot("基线", raster("#111111"));
|
||||
const candidate = snapshot("候选", {
|
||||
...raster("#111111"),
|
||||
widthPx: 41,
|
||||
});
|
||||
candidate.pages[0]!.widthPt = baseline.pages[0]!.widthPt + 0.36;
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
thresholds: {
|
||||
maxMeanAbsoluteError: 255,
|
||||
maxChangedPixelRatio: 1,
|
||||
minInkIou: 0,
|
||||
minEdgeIou: 0,
|
||||
},
|
||||
});
|
||||
expect(report.pages[0]?.metrics).toMatchObject({
|
||||
dimensionsMatch: false,
|
||||
geometryNormalized: true,
|
||||
comparedWidthPx: 40,
|
||||
});
|
||||
expect(report.issues.map((issue) => issue.code)).not.toContain(
|
||||
"RASTER_SIZE_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("展示并校验逐页逻辑页码和位置", async () => {
|
||||
const baseline = snapshot("Chromium", raster("#111111"));
|
||||
const candidate = snapshot("Word", raster("#111111"));
|
||||
const pageNumberLine = (x: number) => ({
|
||||
text: "— 1 —",
|
||||
normalizedText: "— 1 —",
|
||||
bounds: { x, y: 22, width: 2, height: 1 },
|
||||
baselineY: 23,
|
||||
role: "page-number" as const,
|
||||
items: [],
|
||||
});
|
||||
baseline.pages[0]!.lines = [pageNumberLine(17)];
|
||||
baseline.pages[0]!.pageNumberText = "— 1 —";
|
||||
candidate.pages[0]!.lines = [pageNumberLine(9)];
|
||||
candidate.pages[0]!.pageNumberText = "— 1 —";
|
||||
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
pageSemantics: [
|
||||
{
|
||||
physicalPageNumber: 1,
|
||||
kind: "body-first",
|
||||
logicalPageNumber: 1,
|
||||
logicalPageCount: 1,
|
||||
headerVisible: false,
|
||||
footerVisible: true,
|
||||
footerAlignment: "right",
|
||||
pageNumberText: "— 1 —",
|
||||
},
|
||||
],
|
||||
});
|
||||
const html = renderPdfVisualDiffHtml(report);
|
||||
|
||||
expect(report.status).toBe("failed");
|
||||
expect(report.pages[0]?.semantics?.candidate).toMatchObject({
|
||||
pageNumberAlignment: "center",
|
||||
});
|
||||
expect(report.issues.map((issue) => issue.code)).toContain(
|
||||
"PAGE_NUMBER_ALIGNMENT_MISMATCH",
|
||||
);
|
||||
expect(html).toContain("页面语义");
|
||||
expect(html).toContain("正文首页");
|
||||
expect(html).toContain("逻辑页 1 / 1");
|
||||
});
|
||||
|
||||
it("拒绝本应隐藏却仍然出现的页眉", async () => {
|
||||
const baseline = snapshot("Chromium", raster("#111111"));
|
||||
const candidate = snapshot("Word", raster("#111111"));
|
||||
const headerLine = {
|
||||
text: "左页眉",
|
||||
normalizedText: "左页眉",
|
||||
bounds: { x: 2, y: 1, width: 4, height: 1 },
|
||||
baselineY: 2,
|
||||
role: "content" as const,
|
||||
items: [],
|
||||
};
|
||||
baseline.pages[0]!.lines = [headerLine];
|
||||
candidate.pages[0]!.lines = [headerLine];
|
||||
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
pageSemantics: [
|
||||
{
|
||||
physicalPageNumber: 1,
|
||||
kind: "cover",
|
||||
logicalPageCount: 1,
|
||||
headerVisible: false,
|
||||
headerSlots: [{ alignment: "left", text: "左页眉" }],
|
||||
footerVisible: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(report.status).toBe("failed");
|
||||
expect(report.issues.map((issue) => issue.code)).toContain(
|
||||
"HEADER_VISIBILITY_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("拒绝页眉和页码纵向基线偏差超过 2pt", async () => {
|
||||
const baseline = snapshot("Chromium", raster("#111111"));
|
||||
const candidate = snapshot("Word", raster("#111111"));
|
||||
baseline.pages[0]!.heightPt = 100;
|
||||
candidate.pages[0]!.heightPt = 100;
|
||||
const lines = (headerY: number, footerY: number) => [
|
||||
{
|
||||
text: "左页眉",
|
||||
normalizedText: "左页眉",
|
||||
bounds: { x: 2, y: headerY - 1, width: 4, height: 1 },
|
||||
baselineY: headerY,
|
||||
role: "content" as const,
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
text: "1 / 1",
|
||||
normalizedText: "1 / 1",
|
||||
bounds: { x: 9, y: footerY - 1, width: 2, height: 1 },
|
||||
baselineY: footerY,
|
||||
role: "page-number" as const,
|
||||
items: [],
|
||||
},
|
||||
];
|
||||
baseline.pages[0]!.lines = lines(2, 95);
|
||||
candidate.pages[0]!.lines = lines(5, 92);
|
||||
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
pageSemantics: [
|
||||
{
|
||||
physicalPageNumber: 1,
|
||||
kind: "body-first",
|
||||
logicalPageNumber: 1,
|
||||
logicalPageCount: 1,
|
||||
headerVisible: true,
|
||||
headerSlots: [{ alignment: "left", text: "左页眉" }],
|
||||
footerVisible: true,
|
||||
footerAlignment: "center",
|
||||
pageNumberText: "1 / 1",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(report.issues.map((issue) => issue.code)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"HEADER_VERTICAL_POSITION_MISMATCH",
|
||||
"PAGE_NUMBER_VERTICAL_POSITION_MISMATCH",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("按文档锚点与页内相对偏移校验页码并容纳 Chromium 自身逐页抖动", async () => {
|
||||
const createTwoPageSnapshot = (
|
||||
label: string,
|
||||
baselines: readonly [number, number],
|
||||
): PdfDocumentSnapshot => {
|
||||
const base = snapshot(label, raster("#111111"));
|
||||
base.pageCount = 2;
|
||||
base.pages = baselines.map((baselineY, index) => ({
|
||||
...base.pages[0]!,
|
||||
pageNumber: index + 1,
|
||||
heightPt: 100,
|
||||
lines: [
|
||||
{
|
||||
text: `${index + 1} / 2`,
|
||||
normalizedText: `${index + 1} / 2`,
|
||||
bounds: { x: 9, y: baselineY - 1, width: 2, height: 1 },
|
||||
baselineY,
|
||||
role: "page-number" as const,
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
}));
|
||||
return base;
|
||||
};
|
||||
const baseline = createTwoPageSnapshot("Chromium", [95, 93.5]);
|
||||
const candidate = createTwoPageSnapshot("Word", [95.8, 95.8]);
|
||||
const semantics: VisualPageSemanticExpectation[] = [1, 2].map(
|
||||
(pageNumber) => ({
|
||||
physicalPageNumber: pageNumber,
|
||||
kind: pageNumber === 1 ? "body-first" : "body-rest",
|
||||
logicalPageNumber: pageNumber,
|
||||
logicalPageCount: 2,
|
||||
headerVisible: false,
|
||||
footerVisible: true,
|
||||
footerAlignment: "center",
|
||||
pageNumberText: `${pageNumber} / 2`,
|
||||
}),
|
||||
);
|
||||
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
pageSemantics: semantics,
|
||||
});
|
||||
|
||||
expect(report.issues.map((issue) => issue.code)).not.toContain(
|
||||
"PAGE_NUMBER_VERTICAL_POSITION_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("将格式一致的整段跨页栅格差异标记为正文流动警告", async () => {
|
||||
const baseline = flowSnapshot(
|
||||
"baseline",
|
||||
[[contentLine("甲", 8)], [contentLine("乙丙", 8)]],
|
||||
"#111111",
|
||||
);
|
||||
const candidate = flowSnapshot(
|
||||
"candidate",
|
||||
[[contentLine("甲", 8), contentLine("乙丙", 12)], []],
|
||||
"#cc0000",
|
||||
);
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
expectedEditableText: "甲乙丙",
|
||||
expectedEditableParagraphs: flowParagraphs,
|
||||
baselinePageSemantics: bodySemantics(2),
|
||||
candidatePageSemantics: bodySemantics(2),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("warning");
|
||||
expect(report.basic.bodyFlow).toMatchObject({
|
||||
detected: true,
|
||||
legal: true,
|
||||
movedParagraphIndexes: [1],
|
||||
});
|
||||
expect(report.pages.map((page) => page.rasterComparisonMode)).toEqual([
|
||||
"body-flow",
|
||||
"body-flow",
|
||||
]);
|
||||
expect(report.issues.map((issue) => issue.code)).toContain(
|
||||
"BODY_FLOW_RASTER_DIFFERENCE",
|
||||
);
|
||||
expect(report.issues.map((issue) => issue.code)).not.toContain(
|
||||
"INK_IOU_BELOW_THRESHOLD",
|
||||
);
|
||||
});
|
||||
|
||||
it("整段跨页同时发生换行变化时仍保持严格失败", async () => {
|
||||
const baseline = flowSnapshot(
|
||||
"baseline",
|
||||
[[contentLine("甲", 8)], [contentLine("乙丙", 8)]],
|
||||
"#111111",
|
||||
);
|
||||
const candidate = flowSnapshot(
|
||||
"candidate",
|
||||
[
|
||||
[contentLine("甲", 8), contentLine("乙", 12), contentLine("丙", 16)],
|
||||
[],
|
||||
],
|
||||
"#cc0000",
|
||||
);
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
expectedEditableText: "甲乙丙",
|
||||
expectedEditableParagraphs: flowParagraphs,
|
||||
baselinePageSemantics: bodySemantics(2),
|
||||
candidatePageSemantics: bodySemantics(2),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("failed");
|
||||
expect(report.basic.bodyFlow).toMatchObject({ detected: true, legal: false });
|
||||
expect(report.pages.every(
|
||||
(page) => page.rasterComparisonMode === "strict",
|
||||
)).toBe(true);
|
||||
expect(report.issues.map((issue) => issue.code)).toContain(
|
||||
"TEXT_BLOCK_LINE_COUNT_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("允许格式一致的正文自然分页产生物理页数差异", async () => {
|
||||
const baseline = flowSnapshot(
|
||||
"baseline",
|
||||
[[contentLine("甲", 8)], [contentLine("乙丙", 8)]],
|
||||
"#111111",
|
||||
);
|
||||
const candidate = flowSnapshot(
|
||||
"candidate",
|
||||
[[contentLine("甲", 8), contentLine("乙丙", 12)]],
|
||||
"#cc0000",
|
||||
);
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
expectedEditableText: "甲乙丙",
|
||||
expectedEditableParagraphs: flowParagraphs,
|
||||
baselinePageSemantics: bodySemantics(2),
|
||||
candidatePageSemantics: bodySemantics(1),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("warning");
|
||||
expect(report.basic.bodyFlow).toMatchObject({
|
||||
legal: true,
|
||||
baselineBodyPageCount: 2,
|
||||
candidateBodyPageCount: 1,
|
||||
});
|
||||
expect(report.issues.map((issue) => issue.code)).toContain(
|
||||
"BODY_FLOW_PAGE_COUNT_DIFFERENCE",
|
||||
);
|
||||
expect(report.pages[1]).toMatchObject({
|
||||
rasterComparisonMode: "body-flow",
|
||||
status: "warning",
|
||||
});
|
||||
});
|
||||
|
||||
it("新增正文页缺少预期页码时不得认定为合法分页流动", async () => {
|
||||
const pageNumber = (text: string): PdfTextLineSnapshot => ({
|
||||
text,
|
||||
normalizedText: text,
|
||||
bounds: { x: 9, y: 22, width: 2, height: 1 },
|
||||
baselineY: 23,
|
||||
role: "page-number",
|
||||
items: [],
|
||||
});
|
||||
const baseline = flowSnapshot(
|
||||
"baseline",
|
||||
[[contentLine("甲", 8), contentLine("乙丙", 12), pageNumber("1 / 1")]],
|
||||
"#111111",
|
||||
);
|
||||
const candidate = flowSnapshot(
|
||||
"candidate",
|
||||
[
|
||||
[contentLine("甲", 8), pageNumber("1 / 2")],
|
||||
[contentLine("乙丙", 8)],
|
||||
],
|
||||
"#cc0000",
|
||||
);
|
||||
const baselineSemantics = bodySemantics(1).map((item) => ({
|
||||
...item,
|
||||
footerVisible: true,
|
||||
footerAlignment: "center" as const,
|
||||
pageNumberText: "1 / 1",
|
||||
}));
|
||||
const candidateSemantics = bodySemantics(2).map((item, index) => ({
|
||||
...item,
|
||||
footerVisible: true,
|
||||
footerAlignment: "center" as const,
|
||||
pageNumberText: `${index + 1} / 2`,
|
||||
}));
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
expectedEditableText: "甲乙丙",
|
||||
expectedEditableParagraphs: flowParagraphs,
|
||||
baselinePageSemantics: baselineSemantics,
|
||||
candidatePageSemantics: candidateSemantics,
|
||||
});
|
||||
|
||||
expect(report.status).toBe("failed");
|
||||
expect(report.basic.bodyFlow).toMatchObject({ detected: true, legal: false });
|
||||
expect(report.issues.map((issue) => issue.code)).toContain(
|
||||
"PAGE_NUMBER_VISIBILITY_MISMATCH",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
aggregatePdfTextLines,
|
||||
buildPageContentText,
|
||||
normalizePdfEditableText,
|
||||
normalizePdfText,
|
||||
type PdfTextItemSnapshot,
|
||||
} from "../src/index.js";
|
||||
@@ -39,6 +40,16 @@ describe("PDF 文本行聚合", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("将字体 ToUnicode 中的传统户部件归一为简体字符", () => {
|
||||
expect(normalizePdfText("戶⼾")).toBe("户户");
|
||||
});
|
||||
|
||||
it("从可编辑正文契约中移除 Word 自动列表装饰符", () => {
|
||||
expect(normalizePdfEditableText("• 项目一 ◦ 子项 ▪ 末项 WPS")).toBe(
|
||||
"项目一子项末项WPS"
|
||||
);
|
||||
});
|
||||
|
||||
it("只在页眉页脚坐标带识别独立页码", () => {
|
||||
const lines = aggregatePdfTextLines(
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user