release: 发布 v0.6.2 DOCX 真实文档修复
新增能力:将 DOCX 发布验收拆分为四套独立 140,支持真实语料冻结、指纹复用、失败与基础设施错误独立统计,并为表格换行、全 JSON 围栏、代码连续性和长文档分页建立通用门禁。 问题修复:冻结 Paged.js 分片前的逻辑表格列轨并传递打印几何,统一 Markdown 表格换行、代码、段落与 OOXML 翻译;改进 PDF 文本流排序、语义块映射、颜色与栅格比较,消除窄字符重叠和跨行范围符号误报。 兼容与部署:版本统一为 0.6.2;正式 Docker 镜像内置固定 Chromium、Pandoc 3.9.0.2 和 Serif/Sans/Mono 字体;Desktop NSIS 与 ZIP 继续直接内置字体,无需系统字体安装。 验证结果:合成基线与长庆严格 280/280,M4N 140/140;健康数据残余误报 6/115(5.22%),均核查为重复表头自动对齐/取样误报且基础设施错误为 0。全项目测试、类型检查、生产构建和 git diff --check 通过;正式 Docker、NSIS、ZIP、离线镜像、部署包、清单及 SHA-256 均已生成并校验。
This commit is contained in:
@@ -121,6 +121,37 @@ describe("Office PDF 适配器", () => {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("Office 偶发未生成 PDF 时清理残留并有界重试", async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), "visual-diff-office-retry-test-"),
|
||||
);
|
||||
const docxPath = join(directory, "fixture.docx");
|
||||
await writeFile(docxPath, "fixture");
|
||||
let attempts = 0;
|
||||
const backend: OfficeAutomationBackend = {
|
||||
probe: async () => true,
|
||||
exportPdf: async (options) => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
await writeFile(options.outputPath, "partial");
|
||||
throw new Error("Office 未生成有效 PDF");
|
||||
}
|
||||
const pdf = createMinimalPdf("retry");
|
||||
await writeFile(options.outputPath, pdf);
|
||||
return { bytes: pdf.byteLength, pageCount: 1 };
|
||||
},
|
||||
};
|
||||
try {
|
||||
const result = await createWordPdfAdapter({ backend }).generate({
|
||||
docxPath,
|
||||
});
|
||||
expect(result.pageCount).toBe(1);
|
||||
expect(attempts).toBe(2);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("PDF 适配器视觉编排", () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -52,6 +52,7 @@ describe("PDF 栅格差异", () => {
|
||||
meanAbsoluteError: 0,
|
||||
changedPixelRatio: 0,
|
||||
inkIou: 1,
|
||||
foregroundInkIou: 1,
|
||||
edgeIou: 1,
|
||||
backgroundColorDelta: 0,
|
||||
foregroundColorDelta: 0,
|
||||
@@ -88,6 +89,96 @@ describe("PDF 栅格差异", () => {
|
||||
expect(sameShape.metrics.foregroundColorDelta).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("双背景占比翻转时仍独立量化核心字形拓扑", async () => {
|
||||
const inlineCodeRaster = (fullHeightShading: boolean) => {
|
||||
const canvas = createCanvas(216, 25);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, 216, 25);
|
||||
context.fillStyle = "#f3f3f3";
|
||||
context.fillRect(28, fullHeightShading ? 0 : 5, 120, fullHeightShading ? 25 : 16);
|
||||
context.fillStyle = "#333333";
|
||||
context.fillRect(34, 10, 80, 4);
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx: 216,
|
||||
heightPx: 25,
|
||||
dpi: 144,
|
||||
sha256: createHash("sha256").update(png).digest("hex"),
|
||||
png,
|
||||
} satisfies PdfPageRaster;
|
||||
};
|
||||
const result = await comparePageRasters(
|
||||
inlineCodeRaster(false),
|
||||
inlineCodeRaster(true),
|
||||
);
|
||||
|
||||
expect(result.metrics.backgroundColorDelta).toBeGreaterThan(10);
|
||||
expect(result.metrics.foregroundInkIou).toBe(1);
|
||||
});
|
||||
|
||||
it("渐变背景不会把深色文字误判为背景", async () => {
|
||||
const gradientRaster = (gradient: boolean) => {
|
||||
const canvas = createCanvas(160, 40);
|
||||
const context = canvas.getContext("2d");
|
||||
if (gradient) {
|
||||
const fill = context.createLinearGradient(0, 0, 160, 0);
|
||||
fill.addColorStop(0, "#edf5fa");
|
||||
fill.addColorStop(1, "#ffffff");
|
||||
context.fillStyle = fill;
|
||||
} else {
|
||||
context.fillStyle = "#f6fafc";
|
||||
}
|
||||
context.fillRect(0, 0, 160, 40);
|
||||
context.fillStyle = "#0b3557";
|
||||
context.fillRect(4, 4, 148, 32);
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx: 160,
|
||||
heightPx: 40,
|
||||
dpi: 144,
|
||||
sha256: createHash("sha256").update(png).digest("hex"),
|
||||
png,
|
||||
} satisfies PdfPageRaster;
|
||||
};
|
||||
|
||||
const result = await comparePageRasters(
|
||||
gradientRaster(true),
|
||||
gradientRaster(false),
|
||||
);
|
||||
expect(result.metrics.foregroundColorDelta).toBeLessThan(1);
|
||||
expect(result.metrics.backgroundColorDelta).toBeLessThan(8);
|
||||
expect(result.metrics.inkIou).toBeGreaterThan(0.95);
|
||||
});
|
||||
|
||||
it("裁剪跨过深色盒边界时不会把外部白色误判为盒背景", async () => {
|
||||
const boxedRaster = (exposeOutside: boolean) => {
|
||||
const canvas = createCanvas(80, 40);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, 80, 40);
|
||||
context.fillStyle = "#464646";
|
||||
context.fillRect(0, 0, exposeOutside ? 68 : 80, exposeOutside ? 32 : 40);
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(18, 10, 32, 16);
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx: 80,
|
||||
heightPx: 40,
|
||||
dpi: 144,
|
||||
sha256: createHash("sha256").update(png).digest("hex"),
|
||||
png,
|
||||
} satisfies PdfPageRaster;
|
||||
};
|
||||
|
||||
const result = await comparePageRasters(
|
||||
boxedRaster(true),
|
||||
boxedRaster(false),
|
||||
);
|
||||
expect(result.metrics.backgroundColorDelta).toBeLessThan(1);
|
||||
expect(result.metrics.foregroundColorDelta).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("量化位移并生成稳定叠加图与热力图", async () => {
|
||||
const result = await comparePageRasters(raster(10), raster(20), {
|
||||
spatialTolerancePx: 0,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createPdfVisualDiffReport,
|
||||
getBlockingVisualDiffIssues,
|
||||
isCrossEngineRasterEquivalent,
|
||||
renderPdfVisualDiffHtml,
|
||||
serializePdfVisualDiffJson,
|
||||
type PdfDocumentSnapshot,
|
||||
@@ -84,6 +85,27 @@ function antialiasBlockRaster(edgeColor: string): PdfPageRaster {
|
||||
};
|
||||
}
|
||||
|
||||
function adjacentLineRaster(includePreviousLine: boolean): PdfPageRaster {
|
||||
const canvas = createCanvas(40, 50);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, 40, 50);
|
||||
if (includePreviousLine) {
|
||||
context.fillStyle = "#111111";
|
||||
context.fillRect(8, 12, 20, 3);
|
||||
}
|
||||
context.fillStyle = "#111111";
|
||||
context.fillRect(8, 20, 20, 4);
|
||||
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,
|
||||
@@ -561,6 +583,58 @@ describe("PDF 视觉差异报告", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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), pageNumber("1 / 2")],
|
||||
[contentLine("乙丙", 8), pageNumber("2 / 2")],
|
||||
],
|
||||
"#111111",
|
||||
);
|
||||
const candidate = flowSnapshot(
|
||||
"candidate",
|
||||
[[
|
||||
contentLine("甲", 8),
|
||||
contentLine("乙丙", 12),
|
||||
pageNumber("1 / 1"),
|
||||
]],
|
||||
"#cc0000",
|
||||
);
|
||||
const pageSemantics = (count: number) => bodySemantics(count).map(
|
||||
(item, index) => ({
|
||||
...item,
|
||||
footerVisible: true,
|
||||
footerAlignment: "center" as const,
|
||||
pageNumberText: `${index + 1} / ${count}`,
|
||||
}),
|
||||
);
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
expectedEditableText: "甲乙丙",
|
||||
expectedEditableParagraphs: flowParagraphs,
|
||||
baselinePageSemantics: pageSemantics(2),
|
||||
candidatePageSemantics: pageSemantics(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",
|
||||
);
|
||||
});
|
||||
|
||||
it("新增正文页缺少预期页码时不得认定为合法分页流动", async () => {
|
||||
const pageNumber = (text: string): PdfTextLineSnapshot => ({
|
||||
text,
|
||||
@@ -664,6 +738,71 @@ describe("PDF 视觉差异报告", () => {
|
||||
.toMatchObject({ inkIou: 1, edgeIou: 1 });
|
||||
});
|
||||
|
||||
it("局部视觉裁剪不得把目标行上方的相邻行墨迹纳入比较", async () => {
|
||||
const targetLine = contentLine("乙", 10);
|
||||
targetLine.bounds = { x: 4, y: 10, width: 10, height: 2 };
|
||||
const baseline = flowSnapshot(
|
||||
"baseline",
|
||||
[[targetLine]],
|
||||
"#111111",
|
||||
);
|
||||
const candidate = flowSnapshot(
|
||||
"candidate",
|
||||
[[targetLine]],
|
||||
"#111111",
|
||||
);
|
||||
baseline.pages[0]!.raster = adjacentLineRaster(true);
|
||||
candidate.pages[0]!.raster = adjacentLineRaster(false);
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
expectedEditableText: "乙",
|
||||
expectedEditableParagraphs: [{
|
||||
index: 0,
|
||||
text: "乙",
|
||||
role: "body",
|
||||
section: "body",
|
||||
blockKind: "table-cell",
|
||||
}],
|
||||
pageSemantics: bodySemantics(1),
|
||||
});
|
||||
|
||||
expect(report.issues.map((issue) => issue.code)).not.toContain(
|
||||
"SEMANTIC_BLOCK_RASTER_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("仅对带行内代码结构语义的混排允许 WPS 底纹栅格混合差异", () => {
|
||||
const metrics = {
|
||||
baselineWidthPx: 236,
|
||||
baselineHeightPx: 24,
|
||||
candidateWidthPx: 233,
|
||||
candidateHeightPx: 26,
|
||||
comparedWidthPx: 236,
|
||||
comparedHeightPx: 26,
|
||||
dimensionsMatch: false,
|
||||
geometryNormalized: true,
|
||||
spatialTolerancePx: 4,
|
||||
meanAbsoluteError: 22.1,
|
||||
changedPixelRatio: 0.449,
|
||||
inkIou: 0.908,
|
||||
edgeIou: 1,
|
||||
backgroundColorDelta: 11.38,
|
||||
foregroundColorDelta: 2.6,
|
||||
};
|
||||
|
||||
expect(isCrossEngineRasterEquivalent(
|
||||
metrics,
|
||||
false,
|
||||
"paragraph",
|
||||
true,
|
||||
)).toBe(true);
|
||||
expect(isCrossEngineRasterEquivalent(
|
||||
metrics,
|
||||
false,
|
||||
"paragraph",
|
||||
false,
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it("缺少语义块证据时不得把正文整页栅格自动降级", async () => {
|
||||
const report = await createPdfVisualDiffReport(
|
||||
snapshot("baseline", raster("#111111")),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCanvas } from "@napi-rs/canvas";
|
||||
|
||||
import {
|
||||
comparePdfSemanticBlockVisuals,
|
||||
isCrossEngineRasterEquivalent,
|
||||
type PdfRasterMetrics,
|
||||
} from "../src/index.js";
|
||||
@@ -28,7 +30,289 @@ function metrics(
|
||||
};
|
||||
}
|
||||
|
||||
function whitePageRaster() {
|
||||
const canvas = createCanvas(400, 400);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#fff";
|
||||
context.fillRect(0, 0, 400, 400);
|
||||
context.fillStyle = "#111";
|
||||
context.fillRect(40, 40, 120, 12);
|
||||
context.fillRect(40, 80, 120, 12);
|
||||
const rgba = context.getImageData(0, 0, 400, 400).data;
|
||||
return {
|
||||
widthPx: 400,
|
||||
heightPx: 400,
|
||||
dpi: 72,
|
||||
sha256: "test",
|
||||
png: Uint8Array.from(canvas.toBuffer("image/png")),
|
||||
rgba,
|
||||
};
|
||||
}
|
||||
|
||||
function blankPageRaster() {
|
||||
const canvas = createCanvas(400, 400);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#fff";
|
||||
context.fillRect(0, 0, 400, 400);
|
||||
const rgba = context.getImageData(0, 0, 400, 400).data;
|
||||
return {
|
||||
widthPx: 400,
|
||||
heightPx: 400,
|
||||
dpi: 72,
|
||||
sha256: "blank",
|
||||
png: Uint8Array.from(canvas.toBuffer("image/png")),
|
||||
rgba,
|
||||
};
|
||||
}
|
||||
|
||||
describe("跨引擎字形栅格等价", () => {
|
||||
it("缺少局部视觉行时必须阻断而不能静默通过", async () => {
|
||||
const [comparison] = await comparePdfSemanticBlockVisuals(
|
||||
{ pages: [] } as never,
|
||||
{ pages: [] } as never,
|
||||
[{
|
||||
expectation: { index: 39, section: "body", blockKind: "table-cell" },
|
||||
baseline: { matched: true, visualLines: [], lineTexts: [] },
|
||||
candidate: { matched: true, visualLines: [], lineTexts: [] },
|
||||
}] as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(comparison?.status).toBe("failed");
|
||||
expect(comparison?.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE",
|
||||
severity: "failure",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("跨引擎换行数不同时按字符区间配对视觉行而不是返回空观测", async () => {
|
||||
const page = {
|
||||
pageNumber: 1,
|
||||
widthPt: 400,
|
||||
heightPt: 400,
|
||||
lines: [],
|
||||
raster: whitePageRaster(),
|
||||
};
|
||||
const visualLine = (y: number) => ({
|
||||
pageNumber: 1,
|
||||
bounds: { x: 40, y, width: 120, height: 12 },
|
||||
fontFamilies: ["Test Sans"],
|
||||
});
|
||||
const [comparison] = await comparePdfSemanticBlockVisuals(
|
||||
{ pages: [page] } as never,
|
||||
{ pages: [page] } as never,
|
||||
[{
|
||||
expectation: {
|
||||
index: 7,
|
||||
section: "body",
|
||||
blockKind: "table-cell",
|
||||
},
|
||||
baseline: {
|
||||
matched: true,
|
||||
visualLines: [visualLine(40), visualLine(80)],
|
||||
lineTexts: ["甲乙", "丙丁"],
|
||||
},
|
||||
candidate: {
|
||||
matched: true,
|
||||
visualLines: [visualLine(40)],
|
||||
lineTexts: ["甲乙丙丁"],
|
||||
},
|
||||
}] as never,
|
||||
{
|
||||
pixelDifferenceThreshold: 8,
|
||||
spatialTolerancePx: 4,
|
||||
minInkIou: 0.75,
|
||||
minEdgeIou: 0.75,
|
||||
maxBackgroundColorDelta: 16,
|
||||
maxForegroundColorDelta: 16,
|
||||
maxMeanAbsoluteError: 20,
|
||||
maxChangedPixelRatio: 0.35,
|
||||
minAntialiasEquivalentInkIou: 0.9,
|
||||
minAntialiasEquivalentEdgeIou: 0.9,
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(comparison?.status).toBe("warning");
|
||||
expect(comparison?.lines).toHaveLength(2);
|
||||
expect(comparison?.issues).not.toContainEqual(
|
||||
expect.objectContaining({ code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE" }),
|
||||
);
|
||||
expect(comparison?.lines.every((line) =>
|
||||
line.issues.some((issue) => issue.details?.textReflow === true)
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it("PDF 文本提取仅遗漏长文本末尾单字时仍比较已有视觉行", async () => {
|
||||
const page = {
|
||||
pageNumber: 1,
|
||||
widthPt: 400,
|
||||
heightPt: 400,
|
||||
lines: [],
|
||||
raster: blankPageRaster(),
|
||||
};
|
||||
const visualLine = (y: number) => ({
|
||||
pageNumber: 1,
|
||||
bounds: { x: 40, y, width: 108, height: 12 },
|
||||
fontFamilies: ["Test Sans"],
|
||||
});
|
||||
const [comparison] = await comparePdfSemanticBlockVisuals(
|
||||
{ pages: [page] } as never,
|
||||
{ pages: [page] } as never,
|
||||
[{
|
||||
expectation: {
|
||||
index: 118,
|
||||
section: "body",
|
||||
blockKind: "table-header",
|
||||
},
|
||||
baseline: {
|
||||
matched: true,
|
||||
matchedCharacterCount: 10,
|
||||
expectedCharacterCount: 10,
|
||||
visualLines: [visualLine(40), visualLine(70)],
|
||||
lineTexts: ["高血压预测模型目标", "值"],
|
||||
},
|
||||
candidate: {
|
||||
matched: false,
|
||||
matchedCharacterCount: 9,
|
||||
expectedCharacterCount: 10,
|
||||
visualLines: [visualLine(40)],
|
||||
lineTexts: ["高血压预测模型目标"],
|
||||
},
|
||||
}] as never,
|
||||
{
|
||||
pixelDifferenceThreshold: 8,
|
||||
spatialTolerancePx: 4,
|
||||
minInkIou: 0.75,
|
||||
minEdgeIou: 0.75,
|
||||
maxBackgroundColorDelta: 16,
|
||||
maxForegroundColorDelta: 16,
|
||||
maxMeanAbsoluteError: 20,
|
||||
maxChangedPixelRatio: 0.35,
|
||||
minAntialiasEquivalentInkIou: 0.9,
|
||||
minAntialiasEquivalentEdgeIou: 0.9,
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(comparison?.lines).toHaveLength(1);
|
||||
expect(comparison?.issues).not.toContainEqual(
|
||||
expect.objectContaining({ code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("表格末尾数学字符未进入 PDF 文本层时仍比较已有视觉行", async () => {
|
||||
const page = {
|
||||
pageNumber: 1,
|
||||
widthPt: 400,
|
||||
heightPt: 400,
|
||||
lines: [],
|
||||
raster: blankPageRaster(),
|
||||
};
|
||||
const visualLine = {
|
||||
pageNumber: 1,
|
||||
bounds: { x: 40, y: 40, width: 108, height: 12 },
|
||||
fontFamilies: ["Test Serif"],
|
||||
};
|
||||
const [comparison] = await comparePdfSemanticBlockVisuals(
|
||||
{ pages: [page] } as never,
|
||||
{ pages: [page] } as never,
|
||||
[{
|
||||
expectation: {
|
||||
index: 71,
|
||||
section: "body",
|
||||
blockKind: "table-cell",
|
||||
mathCharacterIndexes: [3, 4, 5, 6, 11, 12, 13],
|
||||
},
|
||||
baseline: {
|
||||
matched: false,
|
||||
matchedCharacterCount: 12,
|
||||
expectedCharacterCount: 14,
|
||||
visualLines: [visualLine],
|
||||
lineTexts: ["收缩压≥140或舒张压≥"],
|
||||
},
|
||||
candidate: {
|
||||
matched: true,
|
||||
matchedCharacterCount: 14,
|
||||
expectedCharacterCount: 14,
|
||||
visualLines: [visualLine],
|
||||
lineTexts: ["收缩压≥140或舒张压≥90"],
|
||||
},
|
||||
}] as never,
|
||||
{
|
||||
pixelDifferenceThreshold: 8,
|
||||
spatialTolerancePx: 4,
|
||||
minInkIou: 0.75,
|
||||
minEdgeIou: 0.75,
|
||||
maxBackgroundColorDelta: 16,
|
||||
maxForegroundColorDelta: 16,
|
||||
maxMeanAbsoluteError: 20,
|
||||
maxChangedPixelRatio: 0.35,
|
||||
minAntialiasEquivalentInkIou: 0.9,
|
||||
minAntialiasEquivalentEdgeIou: 0.9,
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(comparison?.lines).toHaveLength(1);
|
||||
expect(comparison?.issues).not.toContainEqual(
|
||||
expect.objectContaining({ code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("视觉行数量相同但字符分段不同时按重叠字符区间比较", async () => {
|
||||
const page = {
|
||||
pageNumber: 1,
|
||||
widthPt: 400,
|
||||
heightPt: 400,
|
||||
lines: [],
|
||||
raster: blankPageRaster(),
|
||||
};
|
||||
const visualLine = (y: number, width: number) => ({
|
||||
pageNumber: 1,
|
||||
bounds: { x: 40, y, width, height: 12 },
|
||||
fontFamilies: ["Test Sans"],
|
||||
});
|
||||
const [comparison] = await comparePdfSemanticBlockVisuals(
|
||||
{ pages: [page] } as never,
|
||||
{ pages: [page] } as never,
|
||||
[{
|
||||
expectation: {
|
||||
index: 117,
|
||||
section: "body",
|
||||
blockKind: "table-header",
|
||||
},
|
||||
baseline: {
|
||||
matched: true,
|
||||
visualLines: [visualLine(40, 20), visualLine(70, 40), visualLine(100, 40)],
|
||||
lineTexts: ["记", "录/", "计算"],
|
||||
},
|
||||
candidate: {
|
||||
matched: true,
|
||||
visualLines: [visualLine(40, 40), visualLine(70, 40), visualLine(100, 20)],
|
||||
lineTexts: ["记录", "/计", "算"],
|
||||
},
|
||||
}] as never,
|
||||
{
|
||||
pixelDifferenceThreshold: 8,
|
||||
spatialTolerancePx: 4,
|
||||
minInkIou: 0.75,
|
||||
minEdgeIou: 0.75,
|
||||
maxBackgroundColorDelta: 16,
|
||||
maxForegroundColorDelta: 16,
|
||||
maxMeanAbsoluteError: 20,
|
||||
maxChangedPixelRatio: 0.35,
|
||||
minAntialiasEquivalentInkIou: 0.9,
|
||||
minAntialiasEquivalentEdgeIou: 0.9,
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(comparison?.status).toBe("warning");
|
||||
expect(comparison?.lines).toHaveLength(5);
|
||||
expect(comparison?.issues).not.toContainEqual(
|
||||
expect.objectContaining({ code: "ELEMENT_COLOR_MISMATCH" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("接受轮廓、颜色和几何一致的灰阶抗锯齿差异", () => {
|
||||
expect(isCrossEngineRasterEquivalent(metrics(), false)).toBe(true);
|
||||
expect(
|
||||
@@ -41,6 +325,71 @@ describe("跨引擎字形栅格等价", () => {
|
||||
false,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 56,
|
||||
candidateWidthPx: 56,
|
||||
baselineHeightPx: 37,
|
||||
candidateHeightPx: 37,
|
||||
comparedWidthPx: 56,
|
||||
comparedHeightPx: 37,
|
||||
inkIou: 1,
|
||||
edgeIou: 1,
|
||||
foregroundColorDelta: 17,
|
||||
baselineForegroundChroma: 0,
|
||||
candidateForegroundChroma: 0,
|
||||
foregroundLuminanceDelta: 17,
|
||||
}),
|
||||
false,
|
||||
"code-block",
|
||||
),
|
||||
).toBe(true);
|
||||
const neutralSingleStrokeMetrics = metrics({
|
||||
baselineWidthPx: 37,
|
||||
candidateWidthPx: 37,
|
||||
baselineHeightPx: 27,
|
||||
candidateHeightPx: 27,
|
||||
comparedWidthPx: 37,
|
||||
comparedHeightPx: 27,
|
||||
meanAbsoluteError: 12.78,
|
||||
inkIou: 1,
|
||||
foregroundInkIou: 1,
|
||||
edgeIou: 1,
|
||||
backgroundColorDelta: 10.54,
|
||||
foregroundColorDelta: 108.69,
|
||||
baselineForegroundChroma: 10,
|
||||
candidateForegroundChroma: 5,
|
||||
foregroundLuminanceDelta: 109.27,
|
||||
});
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
neutralSingleStrokeMetrics,
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...neutralSingleStrokeMetrics, meanAbsoluteError: 16.01 },
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...neutralSingleStrokeMetrics, candidateForegroundChroma: 12.01 },
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...neutralSingleStrokeMetrics, edgeIou: 0.994 },
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({ inkIou: 1, edgeIou: 0.946 }),
|
||||
@@ -97,6 +446,26 @@ describe("跨引擎字形栅格等价", () => {
|
||||
"list-item",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 262,
|
||||
candidateWidthPx: 272,
|
||||
baselineHeightPx: 30,
|
||||
candidateHeightPx: 31,
|
||||
comparedWidthPx: 262,
|
||||
comparedHeightPx: 30,
|
||||
inkIou: 0.9792,
|
||||
edgeIou: 0.9869,
|
||||
foregroundColorDelta: 4.63,
|
||||
baselineForegroundChroma: 0,
|
||||
candidateForegroundChroma: 0,
|
||||
foregroundLuminanceDelta: 4.63,
|
||||
}),
|
||||
false,
|
||||
"list-item",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
@@ -120,6 +489,147 @@ describe("跨引擎字形栅格等价", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("仅在高彩度字形轮廓与前景主色均一致时接受调色差异", () => {
|
||||
const paletteMetrics = metrics({
|
||||
inkIou: 1,
|
||||
edgeIou: 1,
|
||||
foregroundColorDelta: 35,
|
||||
baselineForegroundChroma: 141,
|
||||
candidateForegroundChroma: 176,
|
||||
dominantForegroundColorDelta: 0.5,
|
||||
});
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(paletteMetrics, false, "table-cell"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...paletteMetrics, dominantForegroundColorDelta: 4.1 },
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...paletteMetrics, edgeIou: 0.994 },
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...paletteMetrics, baselineForegroundChroma: 79 },
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("多色代码行仅在语法主色和对应墨迹位置均一致时通过", () => {
|
||||
const syntaxMetrics = metrics({
|
||||
baselineWidthPx: 407,
|
||||
candidateWidthPx: 403,
|
||||
baselineHeightPx: 22,
|
||||
candidateHeightPx: 23,
|
||||
inkIou: 0.988,
|
||||
foregroundInkIou: 1,
|
||||
edgeIou: 1,
|
||||
foregroundColorDelta: 92,
|
||||
chromaticPaletteDelta: 0.8,
|
||||
});
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(syntaxMetrics, false, "code-block"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...syntaxMetrics, chromaticPaletteDelta: 2.1 },
|
||||
false,
|
||||
"code-block",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...syntaxMetrics, foregroundInkIou: 0.994 },
|
||||
false,
|
||||
"code-block",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(syntaxMetrics, false, "paragraph"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("仅在行内代码轮廓和底纹严格一致时接受微小前景色差", () => {
|
||||
const inlineCodeMetrics = metrics({
|
||||
baselineWidthPx: 236,
|
||||
candidateWidthPx: 234,
|
||||
baselineHeightPx: 24,
|
||||
candidateHeightPx: 25,
|
||||
comparedWidthPx: 236,
|
||||
comparedHeightPx: 25,
|
||||
inkIou: 0.91759,
|
||||
edgeIou: 1,
|
||||
backgroundColorDelta: 11.317,
|
||||
foregroundColorDelta: 3.158,
|
||||
});
|
||||
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
inlineCodeMetrics,
|
||||
false,
|
||||
"paragraph",
|
||||
true,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...inlineCodeMetrics, foregroundColorDelta: 3.26 },
|
||||
false,
|
||||
"paragraph",
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...inlineCodeMetrics, backgroundColorDelta: 12.01 },
|
||||
false,
|
||||
"paragraph",
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...inlineCodeMetrics, edgeIou: 0.994 },
|
||||
false,
|
||||
"paragraph",
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{
|
||||
...inlineCodeMetrics,
|
||||
inkIou: 0.88,
|
||||
foregroundInkIou: 0.99,
|
||||
},
|
||||
false,
|
||||
"table-cell",
|
||||
true,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{
|
||||
...inlineCodeMetrics,
|
||||
inkIou: 0.88,
|
||||
foregroundInkIou: 0.97,
|
||||
},
|
||||
false,
|
||||
"table-cell",
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("拒绝回流、颜色、几何或轮廓变化", () => {
|
||||
expect(isCrossEngineRasterEquivalent(metrics(), true)).toBe(false);
|
||||
expect(
|
||||
@@ -169,6 +679,42 @@ describe("跨引擎字形栅格等价", () => {
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(metrics({ edgeIou: 0.96 }), false),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 262,
|
||||
candidateWidthPx: 272,
|
||||
baselineHeightPx: 30,
|
||||
candidateHeightPx: 31,
|
||||
inkIou: 0.9792,
|
||||
edgeIou: 0.9869,
|
||||
foregroundColorDelta: 4.63,
|
||||
baselineForegroundChroma: 2.01,
|
||||
candidateForegroundChroma: 0,
|
||||
foregroundLuminanceDelta: 4.63,
|
||||
}),
|
||||
false,
|
||||
"list-item",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 262,
|
||||
candidateWidthPx: 272,
|
||||
baselineHeightPx: 30,
|
||||
candidateHeightPx: 31,
|
||||
inkIou: 0.9792,
|
||||
edgeIou: 0.9869,
|
||||
foregroundColorDelta: 5.01,
|
||||
baselineForegroundChroma: 0,
|
||||
candidateForegroundChroma: 0,
|
||||
foregroundLuminanceDelta: 5.01,
|
||||
}),
|
||||
false,
|
||||
"list-item",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
|
||||
@@ -40,9 +40,88 @@ describe("PDF 文本行聚合", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("合并 Office 同一视觉行内小于字体高度两成的基线漂移", () => {
|
||||
const left = item("源系统", 10, 100, 30, 10);
|
||||
const middle = item("nut_pickup_order.meal_type", 42, 101.8, 140, 10);
|
||||
const right = item("原值", 184, 100, 20, 10);
|
||||
const lines = aggregatePdfTextLines([left, middle, right], 842);
|
||||
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]?.normalizedText).toBe(
|
||||
"源系统nut_pickup_order.meal_type原值",
|
||||
);
|
||||
});
|
||||
|
||||
it("将同行公式的上标片段按横坐标并回正文", () => {
|
||||
const lines = aggregatePdfTextLines(
|
||||
[
|
||||
item("减因子", 10, 100, 42, 12),
|
||||
item("e", 53, 97, 8, 15),
|
||||
item("−λΔt", 62, 96, 24, 10),
|
||||
item("的动态权重", 87, 100, 70, 12),
|
||||
],
|
||||
842,
|
||||
);
|
||||
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]?.normalizedText).toBe("减因子e−λΔt的动态权重");
|
||||
});
|
||||
|
||||
it("将超出正文右缘但紧邻行末的公式片段并回正文", () => {
|
||||
const lines = aggregatePdfTextLines(
|
||||
[
|
||||
item("性、记忆重要程度、时效性衰减权重(须支持基于时间衰减因子 e", 150, 469.5, 346, 12),
|
||||
item("−λΔt", 495.7, 466.1, 26, 10.2),
|
||||
item("下一视觉行", 150, 489.7, 60, 12),
|
||||
],
|
||||
842,
|
||||
);
|
||||
|
||||
expect(lines.map((line) => line.normalizedText)).toEqual([
|
||||
"性、记忆重要程度、时效性衰减权重(须支持基于时间衰减因子 e−λΔt",
|
||||
"下一视觉行",
|
||||
]);
|
||||
});
|
||||
|
||||
it("将字体 ToUnicode 中的传统户部件归一为简体字符", () => {
|
||||
expect(normalizePdfText("戶⼾")).toBe("户户");
|
||||
expect(normalizePdfText("⻅⻆⻓⻔⻚⻛")).toBe("见角长门页风");
|
||||
expect(normalizePdfText("⻅⻆⻓⻔⻚⻛⻝⻣⻋⻬")).toBe(
|
||||
"见角长门页风食骨车齐",
|
||||
);
|
||||
});
|
||||
|
||||
it("统一 Chromium 与 Office PDF 文本层的中英文弯引号", () => {
|
||||
expect(normalizePdfText("“记录”与‘计算’")).toBe('"记录"与\'计算\'');
|
||||
});
|
||||
|
||||
it("保留与全角括号重叠的窄引号文本流顺序", () => {
|
||||
const lines = aggregatePdfTextLines(
|
||||
[
|
||||
item("主观定性判断", 532.3, 151.5, 58.5),
|
||||
item("”", 590.8, 151.5, 3.4),
|
||||
item("(如", 589.3, 151.5, 19.5),
|
||||
item("“", 608.8, 151.5, 3.4),
|
||||
item("高心率占比", 612.2, 151.5, 48.8),
|
||||
],
|
||||
595,
|
||||
);
|
||||
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]?.normalizedText).toBe('主观定性判断"(如"高心率占比');
|
||||
});
|
||||
|
||||
it("忽略 Emoji 文本层可选的变体选择符", () => {
|
||||
expect(normalizePdfText("⚠️ 提示")).toBe("⚠ 提示");
|
||||
});
|
||||
|
||||
it("统一 CJK 字体文本层的 em dash 与 horizontal bar", () => {
|
||||
expect(normalizePdfText("仓库―仓区")).toBe("仓库—仓区");
|
||||
});
|
||||
|
||||
it("统一数字区间中被 Chromium 提取为双连字符的 en dash", () => {
|
||||
expect(normalizePdfText("10% -- 20%")).toBe("10%–20%");
|
||||
expect(normalizePdfText("10% --")).toBe("10%–");
|
||||
expect(normalizePdfText("命令 --flag")).toBe("命令 --flag");
|
||||
});
|
||||
|
||||
it("从可编辑正文契约中移除 Word 自动列表装饰符", () => {
|
||||
|
||||
Reference in New Issue
Block a user