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:
SkyJourney
2026-08-26 10:50:20 +08:00
parent 01b06abc2c
commit 64445322eb
75 changed files with 9255 additions and 298 deletions
@@ -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 自动列表装饰符", () => {