新增能力:将 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 均已生成并校验。
2387 lines
97 KiB
TypeScript
2387 lines
97 KiB
TypeScript
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 itemLine(
|
||
text: string,
|
||
y: number,
|
||
width = 120,
|
||
x = 72,
|
||
): PdfTextLineSnapshot {
|
||
const result = line(text, y, width, x);
|
||
result.items = [{
|
||
text,
|
||
normalizedText: text,
|
||
bounds: { ...result.bounds },
|
||
baselineY: y,
|
||
hasEol: true,
|
||
}];
|
||
return result;
|
||
}
|
||
|
||
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("只在 OMML 数学字符位置容忍 WPS 文本层字母占位符", () => {
|
||
const expected = "减因子e−λΔt的动态权重";
|
||
const baseline = snapshot("baseline", [[line(expected, 100)]]);
|
||
const candidate = snapshot(
|
||
"candidate",
|
||
[[line("减因子A−AΔA的动态权重", 100)]],
|
||
);
|
||
const result = comparePdfEditableParagraphLayouts(
|
||
baseline,
|
||
candidate,
|
||
[expectation(expected, {
|
||
mathCharacterIndexes: [3, 4, 5, 6, 7],
|
||
})],
|
||
)[0];
|
||
|
||
expect(result?.baseline.matched).toBe(true);
|
||
expect(result?.candidate.matched).toBe(true);
|
||
});
|
||
|
||
it("数学公式被 PDF 文本层提前抽取时仍能映射公式后的正文", () => {
|
||
const expected = "衰减因子e−λΔt的动态权重调整";
|
||
const baseline = snapshot("baseline", [[
|
||
line("−λΔt", 96),
|
||
line("衰减因子e", 100),
|
||
line("的动态权重调整", 116),
|
||
]]);
|
||
const candidate = snapshot("candidate", [[
|
||
line("−λΔt", 96),
|
||
line("衰减因子e", 100),
|
||
line("的动态权重调整", 116),
|
||
]]);
|
||
const result = comparePdfEditableParagraphLayouts(
|
||
baseline,
|
||
candidate,
|
||
[expectation(expected, {
|
||
mathCharacterIndexes: [4, 5, 6, 7, 8],
|
||
})],
|
||
)[0];
|
||
|
||
expect(result?.baseline.matched).toBe(true);
|
||
expect(result?.candidate.matched).toBe(true);
|
||
expect(result?.baseline.matchedCharacterCount).toBe(
|
||
Array.from(expected).length,
|
||
);
|
||
});
|
||
|
||
it("表格硬换行跨视觉行时允许相邻单元格文本交错", () => {
|
||
const visualLines = [
|
||
line("标签第一行旁甲", 200),
|
||
line("第二行旁乙", 220),
|
||
line("第三行", 240),
|
||
line("下一行", 260),
|
||
];
|
||
const paragraphs = [
|
||
expectation("标签", { index: 0, blockKind: "table-cell" }),
|
||
expectation("第一行第二行第三行", {
|
||
index: 1,
|
||
blockKind: "table-cell",
|
||
hardBreakSegments: ["第一行", "第二行", "第三行"],
|
||
}),
|
||
expectation("旁甲旁乙", {
|
||
index: 2,
|
||
blockKind: "table-cell",
|
||
hardBreakSegments: ["旁甲", "旁乙"],
|
||
}),
|
||
expectation("下一行", { index: 3, blockKind: "table-cell" }),
|
||
];
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [visualLines]),
|
||
snapshot("candidate", [visualLines]),
|
||
paragraphs,
|
||
);
|
||
|
||
expect(results.every((result) => result.baseline.matched)).toBe(true);
|
||
expect(results.every((result) => result.candidate.matched)).toBe(true);
|
||
expect(results.every((result) => result.status === "passed")).toBe(true);
|
||
expect(results[1]?.baseline.lineTexts).toEqual([
|
||
"第一行",
|
||
"第二行",
|
||
"第三行",
|
||
]);
|
||
});
|
||
|
||
it("多行表格按单元格语义匹配而不依赖 PDF 的视觉行顺序", () => {
|
||
const visualLines = [
|
||
line("甲列丙列", 200),
|
||
line("乙列丁列", 220),
|
||
line("表格后正文", 260),
|
||
];
|
||
const paragraphs = [
|
||
expectation("甲列乙列", { index: 0, blockKind: "table-cell" }),
|
||
expectation("丙列丁列", { index: 1, blockKind: "table-cell" }),
|
||
expectation("表格后正文", { index: 2 }),
|
||
];
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [visualLines]),
|
||
snapshot("candidate", [visualLines]),
|
||
paragraphs,
|
||
);
|
||
|
||
expect(results.every((result) => result.baseline.matched)).toBe(true);
|
||
expect(results.every((result) => result.candidate.matched)).toBe(true);
|
||
});
|
||
|
||
it("同一表格中的重复短文本只消费各自的字符范围", () => {
|
||
const visualLines = [
|
||
line("表名一DIM已实现", 200),
|
||
line("表名二DIM已实现", 220),
|
||
line("表格后正文", 260),
|
||
];
|
||
const table = { blockKind: "table-cell" as const, tableGroupId: "table-0" };
|
||
const paragraphs = [
|
||
expectation("表名一", { index: 0, ...table }),
|
||
expectation("DIM", { index: 1, ...table }),
|
||
expectation("已实现", { index: 2, ...table }),
|
||
expectation("表名二", { index: 3, ...table }),
|
||
expectation("DIM", { index: 4, ...table }),
|
||
expectation("已实现", { index: 5, ...table }),
|
||
expectation("表格后正文", { index: 6 }),
|
||
];
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [visualLines]),
|
||
snapshot("candidate", [visualLines]),
|
||
paragraphs,
|
||
);
|
||
|
||
expect(results.every((result) => result.baseline.matched)).toBe(true);
|
||
expect(results[1]?.baseline.firstLineBaselineYPt).toBe(200);
|
||
expect(results[4]?.baseline.firstLineBaselineYPt).toBe(220);
|
||
expect(results[6]?.baseline.firstLineBaselineYPt).toBe(260);
|
||
});
|
||
|
||
it("带稳定行列坐标的表格不会把重复短文本匹配到后续章节", () => {
|
||
const tableLine = (
|
||
cells: readonly string[],
|
||
y: number,
|
||
xPositions: readonly number[],
|
||
) => {
|
||
const result = line(cells.join(""), y, 640, xPositions[0]);
|
||
result.items = cells.map((text, index) => ({
|
||
text,
|
||
normalizedText: text,
|
||
bounds: {
|
||
x: xPositions[index]!,
|
||
y: y - 10,
|
||
width: Math.max(12, Array.from(text).length * 12),
|
||
height: 12,
|
||
},
|
||
baselineY: y,
|
||
hasEol: index === cells.length - 1,
|
||
}));
|
||
return result;
|
||
};
|
||
const columns = [72, 150, 420, 500];
|
||
const visualLines = [
|
||
line("按模块统计", 160),
|
||
tableLine(["章节", "模块", "DIM", "合计"], 200, columns),
|
||
tableLine(["一", "维度层", "13", "13"], 220, columns),
|
||
tableLine(["二", "溯源总览", "0", "4"], 240, columns),
|
||
line("后续章节包含DIM以及数字0和4", 600),
|
||
];
|
||
const table = {
|
||
blockKind: "table-cell" as const,
|
||
tableGroupId: "table-0",
|
||
};
|
||
const paragraphs = [
|
||
expectation("按模块统计", { index: 0, blockKind: "heading" }),
|
||
...[
|
||
["章节", 0, 0], ["模块", 0, 1], ["DIM", 0, 2], ["合计", 0, 3],
|
||
["一", 1, 0], ["维度层", 1, 1], ["13", 1, 2], ["13", 1, 3],
|
||
["二", 2, 0], ["溯源总览", 2, 1], ["0", 2, 2], ["4", 2, 3],
|
||
].map(([text, row, column], index) =>
|
||
expectation(text as string, {
|
||
index: index + 1,
|
||
...table,
|
||
tableRowIndex: row as number,
|
||
tableColumnIndex: column as number,
|
||
})
|
||
),
|
||
expectation("后续章节包含DIM以及数字0和4", {
|
||
index: 13,
|
||
blockKind: "paragraph",
|
||
}),
|
||
];
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [visualLines]),
|
||
snapshot("candidate", [visualLines]),
|
||
paragraphs,
|
||
);
|
||
|
||
expect(results.every((result) => result.baseline.matched)).toBe(true);
|
||
expect(results.slice(1, 13).map((result) =>
|
||
result.baseline.firstLineBaselineYPt
|
||
)).toEqual([
|
||
200, 200, 200, 200,
|
||
220, 220, 220, 220,
|
||
240, 240, 240, 240,
|
||
]);
|
||
expect(results[13]?.baseline.firstLineBaselineYPt).toBe(600);
|
||
});
|
||
|
||
it("前一表长单元格不完整时不会污染后一表的稳定行列定位", () => {
|
||
const visualLines = [
|
||
line("第一表标题", 100),
|
||
line("层级说明", 140),
|
||
line("ODS短说明", 160),
|
||
line("第二表标题", 220),
|
||
line("章节模块", 260),
|
||
line("一溯源总览", 280),
|
||
line("文档后部再次出现章节模块一溯源总览", 700),
|
||
];
|
||
const paragraphs = [
|
||
expectation("第一表标题", { index: 0, blockKind: "heading" }),
|
||
expectation("层级", {
|
||
index: 1,
|
||
blockKind: "table-header",
|
||
tableGroupId: "table-0",
|
||
tableRowIndex: 0,
|
||
tableColumnIndex: 0,
|
||
}),
|
||
expectation("说明", {
|
||
index: 2,
|
||
blockKind: "table-header",
|
||
tableGroupId: "table-0",
|
||
tableRowIndex: 0,
|
||
tableColumnIndex: 1,
|
||
}),
|
||
expectation("ODS", {
|
||
index: 3,
|
||
blockKind: "table-cell",
|
||
tableGroupId: "table-0",
|
||
tableRowIndex: 1,
|
||
tableColumnIndex: 0,
|
||
}),
|
||
expectation("这是一段在PDF中被截断的长说明", {
|
||
index: 4,
|
||
blockKind: "table-cell",
|
||
tableGroupId: "table-0",
|
||
tableRowIndex: 1,
|
||
tableColumnIndex: 1,
|
||
}),
|
||
expectation("第二表标题", { index: 5, blockKind: "heading" }),
|
||
expectation("章节", {
|
||
index: 6,
|
||
blockKind: "table-header",
|
||
tableGroupId: "table-1",
|
||
tableRowIndex: 0,
|
||
tableColumnIndex: 0,
|
||
}),
|
||
expectation("模块", {
|
||
index: 7,
|
||
blockKind: "table-header",
|
||
tableGroupId: "table-1",
|
||
tableRowIndex: 0,
|
||
tableColumnIndex: 1,
|
||
}),
|
||
expectation("一", {
|
||
index: 8,
|
||
blockKind: "table-cell",
|
||
tableGroupId: "table-1",
|
||
tableRowIndex: 1,
|
||
tableColumnIndex: 0,
|
||
}),
|
||
expectation("溯源总览", {
|
||
index: 9,
|
||
blockKind: "table-cell",
|
||
tableGroupId: "table-1",
|
||
tableRowIndex: 1,
|
||
tableColumnIndex: 1,
|
||
}),
|
||
];
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [visualLines]),
|
||
snapshot("candidate", [visualLines]),
|
||
paragraphs,
|
||
);
|
||
|
||
expect(results[4]?.baseline.matched).toBe(false);
|
||
expect(results[5]?.baseline.firstLineBaselineYPt).toBe(220);
|
||
expect(results.slice(6).map((result) =>
|
||
result.baseline.firstLineBaselineYPt
|
||
)).toEqual([260, 260, 280, 280]);
|
||
});
|
||
|
||
it("表格结束后同名标题不会复用已消费的单元格文本", () => {
|
||
const visualLines = [
|
||
line("食安监控状态正常", 200),
|
||
line("食安监控", 260),
|
||
];
|
||
const paragraphs = [
|
||
expectation("食安监控", {
|
||
index: 0,
|
||
blockKind: "table-cell",
|
||
tableGroupId: "table-0",
|
||
}),
|
||
expectation("状态正常", {
|
||
index: 1,
|
||
blockKind: "table-cell",
|
||
tableGroupId: "table-0",
|
||
}),
|
||
expectation("食安监控", { index: 2, blockKind: "heading" }),
|
||
];
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [visualLines]),
|
||
snapshot("candidate", [visualLines]),
|
||
paragraphs,
|
||
);
|
||
|
||
expect(results.every((result) => result.baseline.matched)).toBe(true);
|
||
expect(results[0]?.baseline.firstLineBaselineYPt).toBe(200);
|
||
expect(results[2]?.baseline.firstLineBaselineYPt).toBe(260);
|
||
});
|
||
|
||
it("同一物理行中的表格单元格分别保留自己的视觉边界", () => {
|
||
const tableLine = line("建设内容 主要能力", 220, 320, 72);
|
||
tableLine.items = [
|
||
{
|
||
text: "建设内容",
|
||
normalizedText: "建设内容",
|
||
bounds: { x: 72, y: 210, width: 48, height: 12 },
|
||
baselineY: 220,
|
||
fontFamily: "Test Serif",
|
||
hasEol: false,
|
||
},
|
||
{
|
||
text: "主要能力",
|
||
normalizedText: "主要能力",
|
||
bounds: { x: 240, y: 210, width: 48, height: 12 },
|
||
baselineY: 220,
|
||
fontFamily: "Test Serif",
|
||
hasEol: true,
|
||
},
|
||
];
|
||
const paragraphs = [
|
||
expectation("建设内容", { index: 0, blockKind: "table-header" }),
|
||
expectation("主要能力", { index: 1, blockKind: "table-header" }),
|
||
];
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[tableLine]]),
|
||
snapshot("candidate", [[tableLine]]),
|
||
paragraphs,
|
||
);
|
||
|
||
expect(results[0]?.baseline.visualLines[0]?.bounds).toEqual(
|
||
tableLine.items[0]?.bounds,
|
||
);
|
||
expect(results[1]?.baseline.visualLines[0]?.bounds).toEqual(
|
||
tableLine.items[1]?.bounds,
|
||
);
|
||
});
|
||
|
||
it("表格换行匹配优先保留目标单元格的最长连续前缀", () => {
|
||
const firstLine = line(
|
||
"B超报告原文描述 B超报告中是否有对应病灶 若B超无相关描述",
|
||
220,
|
||
480,
|
||
72,
|
||
);
|
||
firstLine.items = [
|
||
{
|
||
text: "B超报告原文描述",
|
||
normalizedText: "B超报告原文描述",
|
||
bounds: { x: 120, y: 210, width: 96, height: 12 },
|
||
baselineY: 220,
|
||
hasEol: false,
|
||
},
|
||
{
|
||
text: "B超报告中是否有对应病灶",
|
||
normalizedText: "B超报告中是否有对应病灶",
|
||
bounds: { x: 280, y: 210, width: 144, height: 12 },
|
||
baselineY: 220,
|
||
hasEol: false,
|
||
},
|
||
{
|
||
text: "若B超无相关描述",
|
||
normalizedText: "若B超无相关描述",
|
||
bounds: { x: 450, y: 210, width: 96, height: 12 },
|
||
baselineY: 220,
|
||
hasEol: true,
|
||
},
|
||
];
|
||
const secondLine = line("描述", 238, 24, 120);
|
||
secondLine.items = [{
|
||
text: "描述",
|
||
normalizedText: "描述",
|
||
bounds: { x: 120, y: 228, width: 24, height: 12 },
|
||
baselineY: 238,
|
||
hasEol: true,
|
||
}];
|
||
const thirdLine = line("描述", 256, 24, 280);
|
||
thirdLine.items = [{
|
||
text: "描述",
|
||
normalizedText: "描述",
|
||
bounds: { x: 280, y: 246, width: 24, height: 12 },
|
||
baselineY: 256,
|
||
hasEol: true,
|
||
}];
|
||
const [result] = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[firstLine, secondLine, thirdLine]]),
|
||
snapshot("candidate", [[firstLine, secondLine, thirdLine]]),
|
||
[expectation("B超报告中是否有对应病灶描述", {
|
||
blockKind: "table-cell",
|
||
})],
|
||
);
|
||
|
||
expect(result?.baseline.visualLines.map((entry) => entry.bounds.x)).toEqual([
|
||
280,
|
||
280,
|
||
]);
|
||
expect(result?.baseline.visualLines[0]?.bounds.width).toBe(144);
|
||
});
|
||
|
||
it("表格多列共享换行后缀时按首行列位置选择续行", () => {
|
||
const firstLine = line(
|
||
"高血压预测模型目 冠心病预测模型目",
|
||
220,
|
||
360,
|
||
72,
|
||
);
|
||
firstLine.items = [
|
||
{
|
||
text: "高血压预测模型目",
|
||
normalizedText: "高血压预测模型目",
|
||
bounds: { x: 180, y: 210, width: 96, height: 12 },
|
||
baselineY: 220,
|
||
hasEol: false,
|
||
},
|
||
{
|
||
text: "冠心病预测模型目",
|
||
normalizedText: "冠心病预测模型目",
|
||
bounds: { x: 300, y: 210, width: 96, height: 12 },
|
||
baselineY: 220,
|
||
hasEol: true,
|
||
},
|
||
];
|
||
const secondLine = line("标值 标值", 238, 144, 180);
|
||
secondLine.items = [
|
||
{
|
||
text: "标值",
|
||
normalizedText: "标值",
|
||
bounds: { x: 180, y: 228, width: 24, height: 12 },
|
||
baselineY: 238,
|
||
hasEol: false,
|
||
},
|
||
{
|
||
text: "标值",
|
||
normalizedText: "标值",
|
||
bounds: { x: 300, y: 228, width: 24, height: 12 },
|
||
baselineY: 238,
|
||
hasEol: true,
|
||
},
|
||
];
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[firstLine, secondLine]]),
|
||
snapshot("candidate", [[firstLine, secondLine]]),
|
||
[
|
||
expectation("高血压预测模型目标值", {
|
||
index: 0,
|
||
blockKind: "table-header",
|
||
}),
|
||
expectation("冠心病预测模型目标值", {
|
||
index: 1,
|
||
blockKind: "table-header",
|
||
}),
|
||
],
|
||
);
|
||
|
||
expect(results[0]?.baseline.visualLines[1]?.bounds.x).toBe(180);
|
||
expect(results[1]?.baseline.visualLines[1]?.bounds.x).toBe(300);
|
||
});
|
||
|
||
it("表格长首行与短续行使用左边界保持在同一单元格", () => {
|
||
const firstLine = line("dim_snd_algo_st", 220, 160, 120);
|
||
firstLine.items = [{
|
||
text: "dim_snd_algo_st",
|
||
normalizedText: "dim_snd_algo_st",
|
||
bounds: { x: 120, y: 210, width: 160, height: 12 },
|
||
baselineY: 220,
|
||
hasEol: true,
|
||
}];
|
||
const continuationLine = line("andard andard", 238, 140, 120);
|
||
continuationLine.items = [
|
||
{
|
||
text: "andard",
|
||
normalizedText: "andard",
|
||
bounds: { x: 120, y: 228, width: 60, height: 12 },
|
||
baselineY: 238,
|
||
hasEol: false,
|
||
},
|
||
{
|
||
text: "andard",
|
||
normalizedText: "andard",
|
||
bounds: { x: 200, y: 228, width: 60, height: 12 },
|
||
baselineY: 238,
|
||
hasEol: true,
|
||
},
|
||
];
|
||
const [result] = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[firstLine, continuationLine]]),
|
||
snapshot("candidate", [[firstLine, continuationLine]]),
|
||
[expectation("dim_snd_algo_standard", { blockKind: "table-cell" })],
|
||
);
|
||
|
||
expect(result?.baseline.lineTexts).toEqual(["dim_snd_algo_st", "andard"]);
|
||
expect(result?.baseline.visualLines[1]?.bounds.x).toBe(120);
|
||
});
|
||
|
||
it("同一物理行中被宽间隙拆开的单元格文本仍在目标列内续接", () => {
|
||
const header = line("维度说明 目标列", 200, 240, 100);
|
||
header.items = [
|
||
{
|
||
text: "维度说明",
|
||
normalizedText: "维度说明",
|
||
bounds: { x: 100, y: 190, width: 48, height: 12 },
|
||
baselineY: 200,
|
||
hasEol: false,
|
||
},
|
||
{
|
||
text: "目标列",
|
||
normalizedText: "目标列",
|
||
bounds: { x: 260, y: 190, width: 36, height: 12 },
|
||
baselineY: 200,
|
||
hasEol: true,
|
||
},
|
||
];
|
||
const value = line("DIM 维度层 DIM 邻列", 220, 260, 100);
|
||
value.items = [
|
||
{
|
||
text: "DIM",
|
||
normalizedText: "DIM",
|
||
bounds: { x: 100, y: 210, width: 24, height: 12 },
|
||
baselineY: 220,
|
||
hasEol: false,
|
||
},
|
||
{
|
||
text: "维度层",
|
||
normalizedText: "维度层",
|
||
bounds: { x: 136, y: 210, width: 36, height: 12 },
|
||
baselineY: 220,
|
||
hasEol: false,
|
||
},
|
||
{
|
||
text: "DIM",
|
||
normalizedText: "DIM",
|
||
bounds: { x: 260, y: 210, width: 24, height: 12 },
|
||
baselineY: 220,
|
||
hasEol: false,
|
||
},
|
||
{
|
||
text: "邻列",
|
||
normalizedText: "邻列",
|
||
bounds: { x: 296, y: 210, width: 24, height: 12 },
|
||
baselineY: 220,
|
||
hasEol: true,
|
||
},
|
||
];
|
||
const table = { tableGroupId: "table-split-items" };
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[header, value]]),
|
||
snapshot("candidate", [[header, value]]),
|
||
[
|
||
expectation("维度说明", {
|
||
index: 0,
|
||
blockKind: "table-header",
|
||
...table,
|
||
tableRowIndex: 0,
|
||
tableColumnIndex: 0,
|
||
}),
|
||
expectation("目标列", {
|
||
index: 1,
|
||
blockKind: "table-header",
|
||
...table,
|
||
tableRowIndex: 0,
|
||
tableColumnIndex: 1,
|
||
}),
|
||
expectation("DIM维度层", {
|
||
index: 2,
|
||
blockKind: "table-cell",
|
||
...table,
|
||
tableRowIndex: 1,
|
||
tableColumnIndex: 0,
|
||
}),
|
||
expectation("DIM邻列", {
|
||
index: 3,
|
||
blockKind: "table-cell",
|
||
...table,
|
||
tableRowIndex: 1,
|
||
tableColumnIndex: 1,
|
||
}),
|
||
],
|
||
);
|
||
|
||
expect(results.map((result) => result.baseline.matched)).toEqual([
|
||
true,
|
||
true,
|
||
true,
|
||
true,
|
||
]);
|
||
expect(results[2]?.baseline.visualLines[0]?.bounds.x).toBe(100);
|
||
expect(results[2]?.baseline.visualLines[0]?.bounds.width).toBe(72);
|
||
expect(results[3]?.baseline.visualLines[0]?.bounds.x).toBe(260);
|
||
});
|
||
|
||
it("相邻列共享前缀时先按列锚点匹配而不是跨列选择更长文本", () => {
|
||
const makeLine = (
|
||
entries: readonly { text: string; x: number; width: number }[],
|
||
baselineY: number,
|
||
) => {
|
||
const value = line(
|
||
entries.map((entry) => entry.text).join(" "),
|
||
baselineY,
|
||
360,
|
||
120,
|
||
);
|
||
value.items = entries.map((entry, index) => ({
|
||
text: entry.text,
|
||
normalizedText: entry.text,
|
||
bounds: {
|
||
x: entry.x,
|
||
y: baselineY - 10,
|
||
width: entry.width,
|
||
height: 12,
|
||
},
|
||
baselineY,
|
||
hasEol: index === entries.length - 1,
|
||
}));
|
||
return value;
|
||
};
|
||
const lines = [
|
||
makeLine([
|
||
{ text: "资产名", x: 190, width: 36 },
|
||
{ text: "设计目标", x: 300, width: 48 },
|
||
], 200),
|
||
makeLine([
|
||
{ text: "算法标准", x: 180, width: 48 },
|
||
{ text: "算法标准统一维度", x: 300, width: 96 },
|
||
], 220),
|
||
makeLine([{ text: "统一维度表", x: 180, width: 60 }], 238),
|
||
];
|
||
const table = { tableGroupId: "table-shared-prefix" };
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [lines]),
|
||
snapshot("candidate", [lines]),
|
||
[
|
||
expectation("资产名", {
|
||
index: 0,
|
||
blockKind: "table-header",
|
||
...table,
|
||
tableRowIndex: 0,
|
||
tableColumnIndex: 0,
|
||
}),
|
||
expectation("设计目标", {
|
||
index: 1,
|
||
blockKind: "table-header",
|
||
...table,
|
||
tableRowIndex: 0,
|
||
tableColumnIndex: 1,
|
||
}),
|
||
expectation("算法标准统一维度表", {
|
||
index: 2,
|
||
blockKind: "table-cell",
|
||
...table,
|
||
tableRowIndex: 1,
|
||
tableColumnIndex: 0,
|
||
}),
|
||
expectation("算法标准统一维度", {
|
||
index: 3,
|
||
blockKind: "table-cell",
|
||
...table,
|
||
tableRowIndex: 1,
|
||
tableColumnIndex: 1,
|
||
}),
|
||
],
|
||
);
|
||
|
||
expect(results[2]?.baseline.matched).toBe(true);
|
||
expect(results[2]?.baseline.visualLines.map((entry) => entry.bounds.x)).toEqual([
|
||
180,
|
||
180,
|
||
]);
|
||
expect(results[3]?.baseline.visualLines[0]?.bounds.x).toBe(300);
|
||
});
|
||
|
||
it("首列表头与相邻列共享更长前缀时按最左列开始匹配", () => {
|
||
const first = line("meal meal_", 200, 140, 80);
|
||
first.items = [
|
||
{
|
||
text: "meal",
|
||
normalizedText: "meal",
|
||
bounds: { x: 80, y: 190, width: 32, height: 12 },
|
||
baselineY: 200,
|
||
hasEol: false,
|
||
},
|
||
{
|
||
text: "meal_",
|
||
normalizedText: "meal_",
|
||
bounds: { x: 160, y: 190, width: 40, height: 12 },
|
||
baselineY: 200,
|
||
hasEol: true,
|
||
},
|
||
];
|
||
const second = line("_typ name", 218, 120, 80);
|
||
second.items = [
|
||
{
|
||
text: "_typ",
|
||
normalizedText: "_typ",
|
||
bounds: { x: 80, y: 208, width: 32, height: 12 },
|
||
baselineY: 218,
|
||
hasEol: false,
|
||
},
|
||
{
|
||
text: "name",
|
||
normalizedText: "name",
|
||
bounds: { x: 160, y: 208, width: 32, height: 12 },
|
||
baselineY: 218,
|
||
hasEol: true,
|
||
},
|
||
];
|
||
const third = line("e _cn", 236, 80, 80);
|
||
third.items = [
|
||
{
|
||
text: "e",
|
||
normalizedText: "e",
|
||
bounds: { x: 80, y: 226, width: 8, height: 12 },
|
||
baselineY: 236,
|
||
hasEol: false,
|
||
},
|
||
{
|
||
text: "_cn",
|
||
normalizedText: "_cn",
|
||
bounds: { x: 160, y: 226, width: 24, height: 12 },
|
||
baselineY: 236,
|
||
hasEol: true,
|
||
},
|
||
];
|
||
const table = { tableGroupId: "table-header-prefix", tableRowIndex: 0 };
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[first, second, third]]),
|
||
snapshot("candidate", [[first, second, third]]),
|
||
[
|
||
expectation("meal_type", {
|
||
index: 0,
|
||
blockKind: "table-header",
|
||
...table,
|
||
tableColumnIndex: 0,
|
||
}),
|
||
expectation("meal_name_cn", {
|
||
index: 1,
|
||
blockKind: "table-header",
|
||
...table,
|
||
tableColumnIndex: 1,
|
||
}),
|
||
],
|
||
);
|
||
|
||
expect(results.map((result) => result.baseline.matched)).toEqual([
|
||
true,
|
||
true,
|
||
]);
|
||
expect(results[0]?.baseline.visualLines[0]?.bounds.x).toBe(80);
|
||
});
|
||
|
||
it("右对齐表头续行按文本中心保持在同一列", () => {
|
||
const first = line("未实现(设计", 200, 80, 400);
|
||
first.items = [{
|
||
text: "未实现(设计",
|
||
normalizedText: "未实现(设计",
|
||
bounds: { x: 400, y: 190, width: 80, height: 12 },
|
||
baselineY: 200,
|
||
hasEol: true,
|
||
}];
|
||
const second = line("态)", 218, 20, 450);
|
||
second.items = [{
|
||
text: "态)",
|
||
normalizedText: "态)",
|
||
bounds: { x: 450, y: 208, width: 20, height: 12 },
|
||
baselineY: 218,
|
||
hasEol: true,
|
||
}];
|
||
const [result] = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[first, second]]),
|
||
snapshot("candidate", [[first, second]]),
|
||
[expectation("未实现(设计态)", {
|
||
blockKind: "table-header",
|
||
tableGroupId: "table-right-header",
|
||
tableRowIndex: 0,
|
||
tableColumnIndex: 0,
|
||
})],
|
||
);
|
||
|
||
expect(result?.baseline.matched).toBe(true);
|
||
expect(result?.baseline.lineTexts).toEqual(["未实现(设计", "态)"]);
|
||
});
|
||
|
||
it("纵向表头续字优先选择后续视觉行中更靠近本列中心的字符", () => {
|
||
const first = line("DW D", 100, 48, 100);
|
||
first.items = [
|
||
{
|
||
text: "DW",
|
||
normalizedText: "DW",
|
||
bounds: { x: 100, y: 90, width: 20, height: 12 },
|
||
baselineY: 100,
|
||
hasEol: false,
|
||
},
|
||
{
|
||
text: "D",
|
||
normalizedText: "D",
|
||
bounds: { x: 134, y: 90, width: 10, height: 12 },
|
||
baselineY: 100,
|
||
hasEol: true,
|
||
},
|
||
];
|
||
const interleaved = line("邻列", 109, 20, 150);
|
||
const continuation = line("D", 118, 10, 104);
|
||
const [result] = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[first, interleaved, continuation]]),
|
||
snapshot("candidate", [[first, interleaved, continuation]]),
|
||
[expectation("DWD", {
|
||
blockKind: "table-header",
|
||
tableGroupId: "table-vertical-header",
|
||
tableRowIndex: 0,
|
||
tableColumnIndex: 0,
|
||
})],
|
||
);
|
||
|
||
expect(result?.baseline.matched).toBe(true);
|
||
expect(result?.baseline.lineTexts).toEqual(["DW", "D"]);
|
||
expect(result?.baseline.visualLines[1]?.bounds.x).toBe(104);
|
||
});
|
||
|
||
it("跨页重复表头与续行同列时优先选择更长的连续匹配", () => {
|
||
const firstLine = line("邻甲", 780, 24, 440);
|
||
firstLine.items = [{
|
||
text: "邻甲",
|
||
normalizedText: "邻甲",
|
||
bounds: { x: 440, y: 770, width: 24, height: 12 },
|
||
baselineY: 780,
|
||
hasEol: true,
|
||
}];
|
||
const repeatedHeader = line("邻接单元格", 54, 60, 440);
|
||
repeatedHeader.items = [{
|
||
text: "邻接单元格",
|
||
normalizedText: "邻接单元格",
|
||
bounds: { x: 440, y: 44, width: 60, height: 12 },
|
||
baselineY: 54,
|
||
hasEol: true,
|
||
}];
|
||
const continuation = line("邻乙", 86, 24, 440);
|
||
continuation.items = [{
|
||
text: "邻乙",
|
||
normalizedText: "邻乙",
|
||
bounds: { x: 440, y: 76, width: 24, height: 12 },
|
||
baselineY: 86,
|
||
hasEol: true,
|
||
}];
|
||
const [result] = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[firstLine], [repeatedHeader, continuation]]),
|
||
snapshot("candidate", [[firstLine], [repeatedHeader, continuation]]),
|
||
[expectation("邻甲邻乙", {
|
||
blockKind: "table-cell",
|
||
hardBreakSegments: ["邻甲", "邻乙"],
|
||
})],
|
||
);
|
||
|
||
expect(result?.baseline.lineTexts).toEqual(["邻甲", "邻乙"]);
|
||
expect(result?.baseline.visualLines).toHaveLength(2);
|
||
expect(result?.baseline.visualLines[1]?.bounds).toEqual(
|
||
continuation.items[0]?.bounds,
|
||
);
|
||
});
|
||
|
||
it("稳定行列坐标下跨页硬换行避开重复表头的同首字前缀", () => {
|
||
const header = line("门禁 内容 邻接单元格", 740, 420, 80);
|
||
header.items = [
|
||
{ text: "门禁", normalizedText: "门禁", bounds: { x: 80, y: 730, width: 24, height: 12 }, baselineY: 740, hasEol: false },
|
||
{ text: "内容", normalizedText: "内容", bounds: { x: 240, y: 730, width: 24, height: 12 }, baselineY: 740, hasEol: false },
|
||
{ text: "邻接单元格", normalizedText: "邻接单元格", bounds: { x: 440, y: 730, width: 60, height: 12 }, baselineY: 740, hasEol: true },
|
||
];
|
||
const row = line("行内代码 正文 邻甲", 780, 420, 80);
|
||
row.items = [
|
||
{ text: "行内代码", normalizedText: "行内代码", bounds: { x: 80, y: 770, width: 48, height: 12 }, baselineY: 780, hasEol: false },
|
||
{ text: "正文", normalizedText: "正文", bounds: { x: 240, y: 770, width: 24, height: 12 }, baselineY: 780, hasEol: false },
|
||
{ text: "邻甲", normalizedText: "邻甲", bounds: { x: 440, y: 770, width: 24, height: 12 }, baselineY: 780, hasEol: true },
|
||
];
|
||
const repeatedHeader = line("门禁 内容 邻接单元格", 54, 420, 80);
|
||
repeatedHeader.items = [
|
||
{ text: "门禁", normalizedText: "门禁", bounds: { x: 80, y: 44, width: 24, height: 12 }, baselineY: 54, hasEol: false },
|
||
{ text: "内容", normalizedText: "内容", bounds: { x: 240, y: 44, width: 24, height: 12 }, baselineY: 54, hasEol: false },
|
||
{ text: "邻接单元格", normalizedText: "邻接单元格", bounds: { x: 440, y: 44, width: 60, height: 12 }, baselineY: 54, fontFamily: "EmbeddedSerif,Bold", hasEol: true },
|
||
];
|
||
const continuation = line("续行 其他 邻乙", 87, 420, 80);
|
||
continuation.items = [
|
||
{ text: "续行", normalizedText: "续行", bounds: { x: 80, y: 77, width: 24, height: 12 }, baselineY: 87, hasEol: false },
|
||
{ text: "其他", normalizedText: "其他", bounds: { x: 240, y: 77, width: 24, height: 12 }, baselineY: 87, hasEol: false },
|
||
{ text: "邻乙", normalizedText: "邻乙", bounds: { x: 440, y: 77, width: 24, height: 12 }, baselineY: 87, hasEol: true },
|
||
];
|
||
const group = { tableGroupId: "table-stable-cross-page-hard-break" };
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[header, row], [repeatedHeader, continuation]]),
|
||
snapshot("candidate", [[header, row], [repeatedHeader, continuation]]),
|
||
[
|
||
expectation("门禁", { index: 0, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 0 }),
|
||
expectation("内容", { index: 1, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 1 }),
|
||
expectation("邻接单元格", { index: 2, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 2 }),
|
||
expectation("行内代码", { index: 3, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 0 }),
|
||
expectation("正文", { index: 4, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 1 }),
|
||
expectation("邻甲邻乙", {
|
||
index: 5,
|
||
blockKind: "table-cell",
|
||
...group,
|
||
tableRowIndex: 1,
|
||
tableColumnIndex: 2,
|
||
hardBreakSegments: ["邻甲", "邻乙"],
|
||
}),
|
||
],
|
||
);
|
||
|
||
expect(results[5]?.baseline.matched).toBe(true);
|
||
expect(results[5]?.candidate.matched).toBe(true);
|
||
expect(results[5]?.baseline.lineTexts).toEqual(["邻甲", "邻乙"]);
|
||
expect(results[5]?.candidate.lineTexts).toEqual(["邻甲", "邻乙"]);
|
||
expect(results[5]?.candidate.visualLines[1]?.fontFamilies).not.toContain(
|
||
"EmbeddedSerif,Bold",
|
||
);
|
||
});
|
||
|
||
it("跨页重复表头与正文续行等长时选择更精确的原列坐标", () => {
|
||
const firstLine = line("已实", 780, 22, 506);
|
||
firstLine.items = [{
|
||
text: "已实",
|
||
normalizedText: "已实",
|
||
bounds: { x: 506, y: 770, width: 22, height: 12 },
|
||
baselineY: 780,
|
||
hasEol: true,
|
||
}];
|
||
const repeatedHeader = line("实现", 54, 22, 508);
|
||
repeatedHeader.items = [{
|
||
text: "实现",
|
||
normalizedText: "实现",
|
||
bounds: { x: 508, y: 44, width: 22, height: 12 },
|
||
baselineY: 54,
|
||
fontFamily: "EmbeddedSerif,Bold",
|
||
hasEol: true,
|
||
}];
|
||
const continuation = line("现", 86, 11, 506);
|
||
continuation.items = [{
|
||
text: "现",
|
||
normalizedText: "现",
|
||
bounds: { x: 506, y: 76, width: 11, height: 12 },
|
||
baselineY: 86,
|
||
fontFamily: "EmbeddedSerif",
|
||
hasEol: true,
|
||
}];
|
||
const [result] = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[firstLine], [repeatedHeader, continuation]]),
|
||
snapshot("candidate", [[firstLine], [repeatedHeader, continuation]]),
|
||
[expectation("已实现", { blockKind: "table-cell" })],
|
||
);
|
||
|
||
expect(result?.baseline.matched).toBe(true);
|
||
expect(result?.baseline.lineTexts).toEqual(["已实", "现"]);
|
||
expect(result?.baseline.visualLines[1]?.bounds).toEqual(
|
||
continuation.items[0]?.bounds,
|
||
);
|
||
expect(result?.baseline.visualLines[1]?.fontFamilies).toEqual([
|
||
"EmbeddedSerif",
|
||
]);
|
||
});
|
||
|
||
it("跨页表格后续列复用前列建立的正文续行基线而避开重复表头", () => {
|
||
const firstPage = line("终端 已实", 780, 180, 80);
|
||
firstPage.items = [
|
||
{ text: "终端", normalizedText: "终端", bounds: { x: 80, y: 770, width: 24, height: 12 }, baselineY: 780, hasEol: false },
|
||
{ text: "已实", normalizedText: "已实", bounds: { x: 220, y: 770, width: 24, height: 12 }, baselineY: 780, hasEol: true },
|
||
];
|
||
const repeatedHeader = line("表名 实现状态", 54, 190, 80);
|
||
repeatedHeader.items = [
|
||
{ text: "表名", normalizedText: "表名", bounds: { x: 80, y: 44, width: 24, height: 12 }, baselineY: 54, hasEol: false },
|
||
{ text: "实现状态", normalizedText: "实现状态", bounds: { x: 222, y: 44, width: 48, height: 12 }, baselineY: 54, fontFamily: "EmbeddedSerif,Bold", hasEol: true },
|
||
];
|
||
const continuation = line("设备维表 现", 104, 180, 80);
|
||
continuation.items = [
|
||
{ text: "设备维表", normalizedText: "设备维表", bounds: { x: 80, y: 94, width: 48, height: 12 }, baselineY: 104, hasEol: false },
|
||
{ text: "现", normalizedText: "现", bounds: { x: 220, y: 94, width: 12, height: 12 }, baselineY: 104, fontFamily: "EmbeddedSerif", hasEol: true },
|
||
];
|
||
const group = { tableGroupId: "table-cross-page-row-anchor", tableRowIndex: 1 };
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[firstPage], [repeatedHeader, continuation]]),
|
||
snapshot("candidate", [[firstPage], [repeatedHeader, continuation]]),
|
||
[
|
||
expectation("终端设备维表", { index: 0, blockKind: "table-cell", ...group, tableColumnIndex: 0 }),
|
||
expectation("已实现", { index: 1, blockKind: "table-cell", ...group, tableColumnIndex: 1 }),
|
||
],
|
||
);
|
||
|
||
expect(results.map((result) => result.baseline.matched)).toEqual([true, true]);
|
||
expect(results[1]?.baseline.lineTexts).toEqual(["已实", "现"]);
|
||
expect(results[1]?.baseline.visualLines[1]?.bounds).toEqual(
|
||
continuation.items[1]?.bounds,
|
||
);
|
||
expect(results[1]?.baseline.visualLines[1]?.fontFamilies).toEqual([
|
||
"EmbeddedSerif",
|
||
]);
|
||
});
|
||
|
||
it("表格行跨页时后续列只从行锚点所在物理页开始匹配", () => {
|
||
const previousPageValue = line("计算", 780, 24, 180);
|
||
const rowLabel = line("DWS", 100, 30, 80);
|
||
const currentPageValue = line("计算", 100, 24, 180);
|
||
const table = {
|
||
tableGroupId: "table-cross-page-row",
|
||
tableRowIndex: 4,
|
||
};
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[previousPageValue], [rowLabel, currentPageValue]]),
|
||
snapshot("candidate", [[previousPageValue], [rowLabel, currentPageValue]]),
|
||
[
|
||
expectation("DWS", {
|
||
index: 0,
|
||
blockKind: "table-cell",
|
||
...table,
|
||
tableColumnIndex: 0,
|
||
}),
|
||
expectation("计算", {
|
||
index: 1,
|
||
blockKind: "table-cell",
|
||
...table,
|
||
tableColumnIndex: 1,
|
||
}),
|
||
],
|
||
);
|
||
|
||
expect(results[1]?.baseline.matched).toBe(true);
|
||
expect(results[1]?.baseline.visualLines[0]?.pageNumber).toBe(2);
|
||
});
|
||
|
||
it("表格首列在当前行已命中有效前缀时不会被稍后更长同名正文抢占", () => {
|
||
const header = line("层级", 740, 20, 80);
|
||
header.items = [{
|
||
text: "层级",
|
||
normalizedText: "层级",
|
||
bounds: { x: 80, y: 730, width: 20, height: 12 },
|
||
baselineY: 740,
|
||
hasEol: true,
|
||
}];
|
||
const firstPart = line("DW", 760, 20, 80);
|
||
const interleaved = line("计算", 780, 24, 180);
|
||
const secondPart = line("S", 800, 10, 80);
|
||
const laterParagraph = line("DWS 后续正文", 820, 120, 80);
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[header, firstPart, interleaved, secondPart, laterParagraph]]),
|
||
snapshot("candidate", [[header, firstPart, interleaved, secondPart, laterParagraph]]),
|
||
[
|
||
expectation("层级", {
|
||
index: 0,
|
||
blockKind: "table-header",
|
||
tableGroupId: "table-current-page-prefix",
|
||
tableRowIndex: 0,
|
||
tableColumnIndex: 0,
|
||
}),
|
||
expectation("DWS", {
|
||
index: 1,
|
||
blockKind: "table-cell",
|
||
tableGroupId: "table-current-page-prefix",
|
||
tableRowIndex: 1,
|
||
tableColumnIndex: 0,
|
||
}),
|
||
],
|
||
);
|
||
const result = results[1];
|
||
|
||
expect(result?.baseline.matched).toBe(true);
|
||
expect(result?.baseline.visualLines.map((entry) => entry.pageNumber)).toEqual([1, 1]);
|
||
expect(result?.baseline.lineTexts).toEqual(["DW", "S"]);
|
||
});
|
||
|
||
it("左对齐数据列可落在居中表头中心边界左侧但不得越过上一列右边界", () => {
|
||
const makeLine = (
|
||
entries: readonly { text: string; x: number; width: number }[],
|
||
baselineY: number,
|
||
) => {
|
||
const value = line(entries.map((entry) => entry.text).join(" "), baselineY, 340, 40);
|
||
value.items = entries.map((entry, index) => ({
|
||
text: entry.text,
|
||
normalizedText: entry.text,
|
||
bounds: { x: entry.x, y: baselineY - 10, width: entry.width, height: 12 },
|
||
baselineY,
|
||
hasEol: index === entries.length - 1,
|
||
}));
|
||
return value;
|
||
};
|
||
const header = makeLine([
|
||
{ text: "章节", x: 46, width: 24 },
|
||
{ text: "模块", x: 192, width: 24 },
|
||
{ text: "DIM", x: 339, width: 26 },
|
||
], 100);
|
||
const body = makeLine([
|
||
{ text: "一", x: 45, width: 12 },
|
||
{ text: "DIM 维度层", x: 84, width: 64 },
|
||
{ text: "13", x: 353, width: 12 },
|
||
], 130);
|
||
const group = { tableGroupId: "table-centered-header-left-data" };
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[header, body]]),
|
||
snapshot("candidate", [[header, body]]),
|
||
[
|
||
expectation("章节", { index: 0, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 0 }),
|
||
expectation("模块", { index: 1, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 1 }),
|
||
expectation("DIM", { index: 2, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 2 }),
|
||
expectation("一", { index: 3, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 0 }),
|
||
expectation("DIM 维度层", { index: 4, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 1 }),
|
||
expectation("13", { index: 5, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 2 }),
|
||
],
|
||
);
|
||
|
||
expect(results.map((result) => result.baseline.matched)).toEqual([
|
||
true,
|
||
true,
|
||
true,
|
||
true,
|
||
true,
|
||
true,
|
||
]);
|
||
expect(results[4]?.baseline.visualLines[0]?.bounds.x).toBe(84);
|
||
});
|
||
|
||
it("同一表格行的后续列可在行锚点所在物理页的较晚视觉行开始", () => {
|
||
const visualLines = [
|
||
line("字段名", 100, 24, 80),
|
||
line("其他列一", 120, 36, 110),
|
||
line("其他列二", 140, 36, 130),
|
||
line("较晚列值", 160, 48, 220),
|
||
];
|
||
const group = { tableGroupId: "table-late-column-start", tableRowIndex: 0 };
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [visualLines]),
|
||
snapshot("candidate", [visualLines]),
|
||
[
|
||
expectation("字段名", {
|
||
index: 0,
|
||
blockKind: "table-header",
|
||
...group,
|
||
tableColumnIndex: 0,
|
||
}),
|
||
expectation("较晚列值", {
|
||
index: 1,
|
||
blockKind: "table-header",
|
||
...group,
|
||
tableColumnIndex: 1,
|
||
}),
|
||
],
|
||
);
|
||
|
||
expect(results[1]?.baseline.matched).toBe(true);
|
||
expect(results[1]?.candidate.matched).toBe(true);
|
||
expect(results[1]?.baseline.visualLines[0]?.pageNumber).toBe(1);
|
||
});
|
||
|
||
it("极窄右对齐表头允许末尾短行相对长前缀发生中心偏移", () => {
|
||
const visualLines = [
|
||
line("字段", 100, 24, 80),
|
||
line("typical_start_ho", 120, 100, 180),
|
||
line("ur", 140, 14, 266),
|
||
];
|
||
const group = { tableGroupId: "table-narrow-header", tableRowIndex: 0 };
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [visualLines]),
|
||
snapshot("candidate", [visualLines]),
|
||
[
|
||
expectation("字段", {
|
||
index: 0,
|
||
blockKind: "table-header",
|
||
...group,
|
||
tableColumnIndex: 0,
|
||
}),
|
||
expectation("typical_start_hour", {
|
||
index: 1,
|
||
blockKind: "table-header",
|
||
...group,
|
||
tableColumnIndex: 1,
|
||
}),
|
||
],
|
||
);
|
||
|
||
expect(results[1]?.baseline.matched).toBe(true);
|
||
expect(results[1]?.candidate.matched).toBe(true);
|
||
expect(results[1]?.baseline.lineTexts).toEqual(["typical_start_ho", "ur"]);
|
||
});
|
||
|
||
it("表格同一单元格的分散对齐字符可跨较大字间距完整匹配", () => {
|
||
const header = line("字段 设计目标 状态", 100, 440, 80);
|
||
header.items = [
|
||
{ text: "字段", normalizedText: "字段", bounds: { x: 80, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: false },
|
||
{ text: "设计目标", normalizedText: "设计目标", bounds: { x: 240, y: 90, width: 48, height: 12 }, baselineY: 100, hasEol: false },
|
||
{ text: "状态", normalizedText: "状态", bounds: { x: 500, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: true },
|
||
];
|
||
const row = line("编号 用 户 营 养 档 案 已实现", 140, 440, 80);
|
||
row.items = [
|
||
{ text: "编号", normalizedText: "编号", bounds: { x: 80, y: 130, width: 24, height: 12 }, baselineY: 140, hasEol: false },
|
||
...Array.from("用户营养档案").map((text, index) => ({
|
||
text,
|
||
normalizedText: text,
|
||
bounds: { x: 240 + index * 34, y: 130, width: 12, height: 12 },
|
||
baselineY: 140,
|
||
hasEol: false,
|
||
})),
|
||
{ text: "已实现", normalizedText: "已实现", bounds: { x: 500, y: 130, width: 36, height: 12 }, baselineY: 140, hasEol: true },
|
||
];
|
||
const group = { tableGroupId: "table-distributed-cell" };
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[header, row]]),
|
||
snapshot("candidate", [[header, row]]),
|
||
[
|
||
expectation("字段", { index: 0, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 0 }),
|
||
expectation("设计目标", { index: 1, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 1 }),
|
||
expectation("状态", { index: 2, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 2 }),
|
||
expectation("编号", { index: 3, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 0 }),
|
||
expectation("用户营养档案", { index: 4, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 1 }),
|
||
expectation("已实现", { index: 5, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 2 }),
|
||
],
|
||
);
|
||
|
||
expect(results.map((result) => result.baseline.matched)).toEqual([
|
||
true, true, true, true, true, true,
|
||
]);
|
||
expect(results[4]?.baseline.lineTexts).toEqual(["用户营养档案"]);
|
||
});
|
||
|
||
it("同列重复短文本按当前表格行的纵向锚点选择", () => {
|
||
const header = line("字段 记录", 100, 220, 80);
|
||
header.items = [
|
||
{ text: "字段", normalizedText: "字段", bounds: { x: 80, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: false },
|
||
{ text: "记录", normalizedText: "记录", bounds: { x: 240, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: true },
|
||
];
|
||
const rowLabel = line("更新时间", 200, 48, 80);
|
||
const rowValue = line("记录", 200, 24, 240);
|
||
const group = { tableGroupId: "table-row-anchor" };
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[header, rowLabel, rowValue]]),
|
||
snapshot("candidate", [[header, rowLabel, rowValue]]),
|
||
[
|
||
expectation("字段", { index: 0, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 0 }),
|
||
expectation("记录", { index: 1, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 1 }),
|
||
expectation("更新时间", { index: 2, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 0 }),
|
||
expectation("记录", { index: 3, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 1 }),
|
||
],
|
||
);
|
||
|
||
expect(results[3]?.baseline.matched).toBe(true);
|
||
expect(results[3]?.baseline.firstLineBaselineYPt).toBe(200);
|
||
});
|
||
|
||
it("同一结构单元格内连续段落不应用横向相邻列边界", () => {
|
||
const projectName = line("智慧园区一体化平台建设项目", 180, 180, 180);
|
||
const reportTitle = line("可行性研究报告", 280, 120, 285);
|
||
const reportVersion = line("送审稿V1.0", 360, 84, 330);
|
||
const reportDate = line("2026年7月", 440, 72, 336);
|
||
const group = {
|
||
tableGroupId: "table-structural-cover",
|
||
tableRowIndex: 0,
|
||
tableColumnIndex: 0,
|
||
blockKind: "table-cell" as const,
|
||
};
|
||
const expectations = [
|
||
expectation("智慧园区一体化平台建设项目", { index: 0, ...group }),
|
||
expectation("可行性研究报告", { index: 1, ...group }),
|
||
expectation("送审稿 V1.0", { index: 2, ...group }),
|
||
expectation("2026年7月", { index: 3, ...group }),
|
||
];
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[projectName, reportTitle, reportVersion, reportDate]]),
|
||
snapshot("candidate", [[projectName, reportTitle, reportVersion, reportDate]]),
|
||
expectations,
|
||
);
|
||
|
||
expect(results.map((result) => result.baseline.matched)).toEqual([
|
||
true,
|
||
true,
|
||
true,
|
||
true,
|
||
]);
|
||
expect(results.map((result) => result.candidate.matched)).toEqual([
|
||
true,
|
||
true,
|
||
true,
|
||
true,
|
||
]);
|
||
});
|
||
|
||
it("同一表格行的后一列允许跨到下一 PDF 页继续匹配", () => {
|
||
const rowLabel = itemLine("肆式", 780, 24, 80);
|
||
const repeatedHeader = itemLine("字段说明", 72, 72, 80);
|
||
const rowValue = itemLine("边界", 108, 24, 180);
|
||
const group = {
|
||
tableGroupId: "table-cross-page-row",
|
||
tableRowIndex: 4,
|
||
blockKind: "table-cell" as const,
|
||
};
|
||
const expectations = [
|
||
expectation("肆式", { index: 0, ...group, tableColumnIndex: 0 }),
|
||
expectation("边界", { index: 1, ...group, tableColumnIndex: 1 }),
|
||
];
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[rowLabel], [repeatedHeader, rowValue]]),
|
||
snapshot("candidate", [[rowLabel], [repeatedHeader, rowValue]]),
|
||
expectations,
|
||
);
|
||
|
||
expect(results.map((result) => result.baseline.matched)).toEqual([true, true]);
|
||
expect(results.map((result) => result.candidate.matched)).toEqual([true, true]);
|
||
});
|
||
|
||
it("合并视觉行中的完整表头不会被下一数据行的较短左移前缀抢占", () => {
|
||
const header = line("门禁内容邻接单元格", 100, 420, 80);
|
||
header.items = [
|
||
{ text: "门禁", normalizedText: "门禁", bounds: { x: 80, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: false },
|
||
{ text: "内容", normalizedText: "内容", bounds: { x: 240, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: false },
|
||
{ text: "邻接单元格", normalizedText: "邻接单元格", bounds: { x: 420, y: 90, width: 60, height: 12 }, baselineY: 100, hasEol: true },
|
||
];
|
||
const rowFirst = line("邻甲", 124, 24, 400);
|
||
const rowLabel = line("行内代码", 132, 48, 80);
|
||
const rowBody = line("单元格正文", 132, 60, 240);
|
||
const rowSecond = line("邻乙", 148, 24, 400);
|
||
const group = { tableGroupId: "table-merged-header-prefix" };
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[header, rowFirst, rowLabel, rowBody, rowSecond]]),
|
||
snapshot("candidate", [[header, rowFirst, rowLabel, rowBody, rowSecond]]),
|
||
[
|
||
expectation("门禁", { index: 0, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 0 }),
|
||
expectation("内容", { index: 1, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 1 }),
|
||
expectation("邻接单元格", { index: 2, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 2 }),
|
||
expectation("行内代码", { index: 3, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 0 }),
|
||
expectation("单元格正文", { index: 4, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 1 }),
|
||
expectation("邻甲邻乙", {
|
||
index: 5,
|
||
blockKind: "table-cell",
|
||
...group,
|
||
tableRowIndex: 1,
|
||
tableColumnIndex: 2,
|
||
hardBreakSegments: ["邻甲", "邻乙"],
|
||
}),
|
||
],
|
||
);
|
||
|
||
expect(results.map((result) => result.baseline.matched)).toEqual([
|
||
true,
|
||
true,
|
||
true,
|
||
true,
|
||
true,
|
||
true,
|
||
]);
|
||
expect(results[2]?.baseline.lineTexts).toEqual(["邻接单元格"]);
|
||
expect(results[5]?.baseline.lineTexts).toEqual(["邻甲", "邻乙"]);
|
||
});
|
||
|
||
it("多段硬换行围绕行中心且首字重复时从当前单元格最早片段开始", () => {
|
||
const header = line("场景 内容", 100, 220, 80);
|
||
header.items = [
|
||
{ text: "场景", normalizedText: "场景", bounds: { x: 80, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: false },
|
||
{ text: "内容", normalizedText: "内容", bounds: { x: 240, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: true },
|
||
];
|
||
const first = line("标甲", 180, 24, 240);
|
||
const second = line("标乙", 200, 24, 240);
|
||
const rowLabel = line("壹式", 210, 24, 80);
|
||
const third = line("标丙", 220, 24, 240);
|
||
const fourth = line("标丁", 240, 24, 240);
|
||
const laterRow = line("大甲", 280, 24, 240);
|
||
const group = { tableGroupId: "table-hard-break-row-center" };
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[header, first, second, rowLabel, third, fourth, laterRow]]),
|
||
snapshot("candidate", [[header, first, second, rowLabel, third, fourth, laterRow]]),
|
||
[
|
||
expectation("场景", { index: 0, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 0 }),
|
||
expectation("内容", { index: 1, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 1 }),
|
||
expectation("壹式", { index: 2, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 0 }),
|
||
expectation("标甲标乙标丙标丁", {
|
||
index: 3,
|
||
blockKind: "table-cell",
|
||
...group,
|
||
tableRowIndex: 1,
|
||
tableColumnIndex: 1,
|
||
hardBreakSegments: ["标甲", "标乙", "标丙", "标丁"],
|
||
}),
|
||
],
|
||
);
|
||
|
||
expect(results[3]?.baseline.matched).toBe(true);
|
||
expect(results[3]?.candidate.matched).toBe(true);
|
||
expect(results[3]?.baseline.lineTexts).toEqual([
|
||
"标甲",
|
||
"标乙",
|
||
"标丙",
|
||
"标丁",
|
||
]);
|
||
});
|
||
|
||
it("两段硬换行优先已锚定数据行而不是上方表头的同首字前缀", () => {
|
||
const header = line("字段 内容 邻", 100, 380, 80);
|
||
header.items = [
|
||
{ text: "字段", normalizedText: "字段", bounds: { x: 80, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: false },
|
||
{ text: "内容", normalizedText: "内容", bounds: { x: 240, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: false },
|
||
{ text: "邻", normalizedText: "邻", bounds: { x: 400, y: 90, width: 12, height: 12 }, baselineY: 100, hasEol: true },
|
||
];
|
||
const row = line("当前行 正文 邻甲", 140, 380, 80);
|
||
row.items = [
|
||
{ text: "当前行", normalizedText: "当前行", bounds: { x: 80, y: 130, width: 36, height: 12 }, baselineY: 140, hasEol: false },
|
||
{ text: "正文", normalizedText: "正文", bounds: { x: 240, y: 130, width: 24, height: 12 }, baselineY: 140, hasEol: false },
|
||
{ text: "邻甲", normalizedText: "邻甲", bounds: { x: 400, y: 130, width: 24, height: 12 }, baselineY: 140, hasEol: true },
|
||
];
|
||
const rowTail = line("邻乙", 160, 24, 400);
|
||
const group = { tableGroupId: "table-two-hard-break-row-anchor" };
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[header, row, rowTail]]),
|
||
snapshot("candidate", [[header, row, rowTail]]),
|
||
[
|
||
expectation("字段", { index: 0, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 0 }),
|
||
expectation("内容", { index: 1, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 1 }),
|
||
expectation("邻", { index: 2, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 2 }),
|
||
expectation("当前行", { index: 3, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 0 }),
|
||
expectation("正文", { index: 4, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 1 }),
|
||
expectation("邻甲邻乙", {
|
||
index: 5,
|
||
blockKind: "table-cell",
|
||
...group,
|
||
tableRowIndex: 1,
|
||
tableColumnIndex: 2,
|
||
hardBreakSegments: ["邻甲", "邻乙"],
|
||
}),
|
||
],
|
||
);
|
||
|
||
expect(results[5]?.baseline.matched).toBe(true);
|
||
expect(results[5]?.candidate.matched).toBe(true);
|
||
expect(results[5]?.baseline.lineTexts).toEqual(["邻甲", "邻乙"]);
|
||
expect(results[5]?.candidate.lineTexts).toEqual(["邻甲", "邻乙"]);
|
||
});
|
||
|
||
it("表格后续列拆行时优先当前行而不是稍后完整同名文本", () => {
|
||
const header = line("字段 状态", 100, 220, 80);
|
||
header.items = [
|
||
{ text: "字段", normalizedText: "字段", bounds: { x: 80, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: false },
|
||
{ text: "状态", normalizedText: "状态", bounds: { x: 240, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: true },
|
||
];
|
||
const rowFirst = line("当前行 已实", 200, 220, 80);
|
||
rowFirst.items = [
|
||
{ text: "当前行", normalizedText: "当前行", bounds: { x: 80, y: 190, width: 36, height: 12 }, baselineY: 200, hasEol: false },
|
||
{ text: "已实", normalizedText: "已实", bounds: { x: 240, y: 190, width: 24, height: 12 }, baselineY: 200, hasEol: true },
|
||
];
|
||
const rowTail = line("现", 213, 12, 240);
|
||
const later = line("已实现", 280, 36, 240);
|
||
const group = { tableGroupId: "table-split-later-column" };
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[header, rowFirst, rowTail, later]]),
|
||
snapshot("candidate", [[header, rowFirst, rowTail, later]]),
|
||
[
|
||
expectation("字段", { index: 0, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 0 }),
|
||
expectation("状态", { index: 1, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 1 }),
|
||
expectation("当前行", { index: 2, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 0 }),
|
||
expectation("已实现", { index: 3, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 1 }),
|
||
],
|
||
);
|
||
|
||
expect(results[3]?.baseline.matched).toBe(true);
|
||
expect(results[3]?.baseline.lineTexts).toEqual(["已实", "现"]);
|
||
expect(results[3]?.baseline.firstLineBaselineYPt).toBe(200);
|
||
});
|
||
|
||
it("长行内代码跨多行时按列左边界聚合且不串入同行其他列", () => {
|
||
const header = line("字段 说明", 100, 260, 80);
|
||
header.items = [
|
||
{ text: "字段", normalizedText: "字段", bounds: { x: 100, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: false },
|
||
{ text: "说明", normalizedText: "说明", bounds: { x: 220, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: true },
|
||
];
|
||
const first = line("nut_prod_sur", 200, 66, 100);
|
||
first.items = [
|
||
{ text: "nut_prod_sur", normalizedText: "nut_prod_sur", bounds: { x: 100, y: 190, width: 66, height: 12 }, baselineY: 200, hasEol: true },
|
||
];
|
||
const second = line("plus_disposal 余量处置明细表", 216, 220, 100);
|
||
second.items = [
|
||
{ text: "plus_disposal", normalizedText: "plus_disposal", bounds: { x: 100, y: 206, width: 72, height: 12 }, baselineY: 216, hasEol: false },
|
||
{ text: "余量处置明细表", normalizedText: "余量处置明细表", bounds: { x: 220, y: 206, width: 84, height: 12 }, baselineY: 216, hasEol: true },
|
||
];
|
||
const third = line("_item", 232, 30, 100);
|
||
third.items = [
|
||
{ text: "_item", normalizedText: "_item", bounds: { x: 100, y: 222, width: 30, height: 12 }, baselineY: 232, hasEol: true },
|
||
];
|
||
const group = { tableGroupId: "table-wrapped-inline-code" };
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[header, first, second, third]]),
|
||
snapshot("candidate", [[header, first, second, third]]),
|
||
[
|
||
expectation("字段", { index: 0, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 0 }),
|
||
expectation("说明", { index: 1, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 1 }),
|
||
expectation("nut_prod_surplus_disposal_item", { index: 2, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 0, hasInlineCode: true }),
|
||
expectation("余量处置明细表", { index: 3, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 1 }),
|
||
],
|
||
);
|
||
|
||
expect(results[2]?.baseline.matched).toBe(true);
|
||
expect(results[2]?.baseline.lineTexts).toEqual([
|
||
"nut_prod_sur",
|
||
"plus_disposal",
|
||
"_item",
|
||
]);
|
||
expect(results[2]?.baseline.visualLines).toHaveLength(3);
|
||
expect(results[3]?.baseline.lineTexts).toEqual(["余量处置明细表"]);
|
||
});
|
||
|
||
it("表格短 ASCII 层级拆行时不会被后续行更长前缀抢占", () => {
|
||
const header = line("资产名 分层", 100, 220, 80);
|
||
header.items = [
|
||
{ text: "资产名", normalizedText: "资产名", bounds: { x: 80, y: 90, width: 36, height: 12 }, baselineY: 100, hasEol: false },
|
||
{ text: "分层", normalizedText: "分层", bounds: { x: 240, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: true },
|
||
];
|
||
const currentRow = line("餐品营养排 D", 200, 220, 80);
|
||
currentRow.items = [
|
||
{ text: "餐品营养排", normalizedText: "餐品营养排", bounds: { x: 80, y: 190, width: 72, height: 12 }, baselineY: 200, hasEol: false },
|
||
{ text: "D", normalizedText: "D", bounds: { x: 240, y: 190, width: 8, height: 12 }, baselineY: 200, hasEol: true },
|
||
];
|
||
const currentRowTail = line("名日表 WS", 213, 220, 80);
|
||
currentRowTail.items = [
|
||
{ text: "名日表", normalizedText: "名日表", bounds: { x: 80, y: 203, width: 36, height: 12 }, baselineY: 213, hasEol: false },
|
||
{ text: "WS", normalizedText: "WS", bounds: { x: 240, y: 203, width: 16, height: 12 }, baselineY: 213, hasEol: true },
|
||
];
|
||
const laterRow = line("后续资产 DW", 360, 220, 80);
|
||
laterRow.items = [
|
||
{ text: "后续资产", normalizedText: "后续资产", bounds: { x: 80, y: 350, width: 48, height: 12 }, baselineY: 360, hasEol: false },
|
||
{ text: "DW", normalizedText: "DW", bounds: { x: 240, y: 350, width: 16, height: 12 }, baselineY: 360, hasEol: true },
|
||
];
|
||
const laterRowTail = line("S", 373, 8, 240);
|
||
const group = { tableGroupId: "table-short-ascii-layer" };
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[header, currentRow, currentRowTail, laterRow, laterRowTail]]),
|
||
snapshot("candidate", [[header, currentRow, currentRowTail, laterRow, laterRowTail]]),
|
||
[
|
||
expectation("资产名", { index: 0, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 0 }),
|
||
expectation("分层", { index: 1, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 1 }),
|
||
expectation("餐品营养排名日表", { index: 2, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 0 }),
|
||
expectation("DWS", { index: 3, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 1 }),
|
||
],
|
||
);
|
||
|
||
expect(results[3]?.baseline.matched).toBe(true);
|
||
expect(results[3]?.candidate.matched).toBe(true);
|
||
expect(results[3]?.baseline.lineTexts).toEqual(["D", "WS"]);
|
||
expect(results[3]?.baseline.firstLineBaselineYPt).toBe(200);
|
||
});
|
||
|
||
it("纵版窄表头允许相邻列在同一逻辑行内上下错位", () => {
|
||
const upper = line("DW DW AD 合", 100, 300, 360);
|
||
upper.items = [
|
||
{ text: "DW", normalizedText: "DW", bounds: { x: 367, y: 90, width: 18, height: 12 }, baselineY: 100, hasEol: false },
|
||
{ text: "DW", normalizedText: "DW", bounds: { x: 409, y: 90, width: 18, height: 12 }, baselineY: 100, hasEol: false },
|
||
{ text: "AD", normalizedText: "AD", bounds: { x: 451, y: 90, width: 18, height: 12 }, baselineY: 100, hasEol: false },
|
||
{ text: "合", normalizedText: "合", bounds: { x: 496, y: 90, width: 12, height: 12 }, baselineY: 100, hasEol: true },
|
||
];
|
||
const lower = line("子节 D S S 计", 121, 320, 204);
|
||
lower.items = [
|
||
{ text: "子节", normalizedText: "子节", bounds: { x: 204, y: 111, width: 24, height: 12 }, baselineY: 121, hasEol: false },
|
||
{ text: "D", normalizedText: "D", bounds: { x: 380, y: 111, width: 9, height: 12 }, baselineY: 121, hasEol: false },
|
||
{ text: "S", normalizedText: "S", bounds: { x: 424, y: 111, width: 9, height: 12 }, baselineY: 121, hasEol: false },
|
||
{ text: "S", normalizedText: "S", bounds: { x: 462, y: 111, width: 9, height: 12 }, baselineY: 121, hasEol: false },
|
||
{ text: "计", normalizedText: "计", bounds: { x: 496, y: 111, width: 12, height: 12 }, baselineY: 121, hasEol: true },
|
||
];
|
||
const group = { tableGroupId: "table-staggered-header", tableRowIndex: 0 };
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[upper, lower]]),
|
||
snapshot("candidate", [[upper, lower]]),
|
||
[
|
||
expectation("子节", { index: 0, blockKind: "table-header", ...group, tableColumnIndex: 0 }),
|
||
expectation("DWD", { index: 1, blockKind: "table-header", ...group, tableColumnIndex: 1 }),
|
||
expectation("DWS", { index: 2, blockKind: "table-header", ...group, tableColumnIndex: 2 }),
|
||
expectation("ADS", { index: 3, blockKind: "table-header", ...group, tableColumnIndex: 3 }),
|
||
expectation("合计", { index: 4, blockKind: "table-header", ...group, tableColumnIndex: 4 }),
|
||
],
|
||
);
|
||
|
||
expect(results.map((result) => result.baseline.matched)).toEqual([
|
||
true, true, true, true, true,
|
||
]);
|
||
expect(results[1]?.baseline.lineTexts).toEqual(["DW", "D"]);
|
||
});
|
||
|
||
it("首次学习窄 ASCII 表头列时按剩余列域从左到右建立锚点", () => {
|
||
const upper = line("DWAD合未实现(设计", 100, 430, 258);
|
||
upper.items = [
|
||
{ text: "DW", normalizedText: "DW", bounds: { x: 258, y: 90, width: 18, height: 12 }, baselineY: 100, hasEol: false },
|
||
{ text: "AD", normalizedText: "AD", bounds: { x: 349, y: 90, width: 18, height: 12 }, baselineY: 100, hasEol: false },
|
||
{ text: "合", normalizedText: "合", bounds: { x: 396, y: 90, width: 12, height: 12 }, baselineY: 100, hasEol: false },
|
||
{ text: "未实现(设计", normalizedText: "未实现(设计", bounds: { x: 442, y: 90, width: 70, height: 12 }, baselineY: 100, hasEol: true },
|
||
];
|
||
const lower = line("承接菜单DDWSS计态)", 114, 440, 81);
|
||
lower.items = [
|
||
{ text: "承接菜单", normalizedText: "承接菜单", bounds: { x: 81, y: 104, width: 72, height: 12 }, baselineY: 114, hasEol: false },
|
||
{ text: "D", normalizedText: "D", bounds: { x: 270, y: 104, width: 9, height: 12 }, baselineY: 114, hasEol: false },
|
||
{ text: "DWS", normalizedText: "DWS", bounds: { x: 297, y: 104, width: 27, height: 12 }, baselineY: 114, hasEol: false },
|
||
{ text: "S", normalizedText: "S", bounds: { x: 360, y: 104, width: 9, height: 12 }, baselineY: 114, hasEol: false },
|
||
{ text: "计", normalizedText: "计", bounds: { x: 396, y: 104, width: 12, height: 12 }, baselineY: 114, hasEol: false },
|
||
{ text: "态)", normalizedText: "态)", bounds: { x: 489, y: 104, width: 22, height: 12 }, baselineY: 114, hasEol: true },
|
||
];
|
||
const group = { tableGroupId: "table-staggered-narrow-ascii", tableRowIndex: 0 };
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[upper, lower]]),
|
||
snapshot("candidate", [[upper, lower]]),
|
||
[
|
||
expectation("承接菜单", { index: 0, blockKind: "table-header", ...group, tableColumnIndex: 0 }),
|
||
expectation("DWD", { index: 1, blockKind: "table-header", ...group, tableColumnIndex: 1 }),
|
||
expectation("DWS", { index: 2, blockKind: "table-header", ...group, tableColumnIndex: 2 }),
|
||
expectation("ADS", { index: 3, blockKind: "table-header", ...group, tableColumnIndex: 3 }),
|
||
expectation("合计", { index: 4, blockKind: "table-header", ...group, tableColumnIndex: 4 }),
|
||
expectation("未实现(设计态)", { index: 5, blockKind: "table-header", ...group, tableColumnIndex: 5 }),
|
||
],
|
||
);
|
||
|
||
expect(results.map((result) => result.baseline.matched)).toEqual([
|
||
true, true, true, true, true, true,
|
||
]);
|
||
expect(results[1]?.baseline.lineTexts).toEqual(["DW", "D"]);
|
||
expect(results[2]?.baseline.lineTexts).toEqual(["DWS"]);
|
||
expect(results[3]?.baseline.lineTexts).toEqual(["AD", "S"]);
|
||
});
|
||
|
||
it("纵向中文表头超过普通行域时不会消费首个数据行的同名字符", () => {
|
||
const headerLines = [
|
||
line("实", 100, 12, 463),
|
||
line("现", 121, 12, 463),
|
||
line("状", 142, 12, 463),
|
||
line("表名态", 163, 390, 99),
|
||
];
|
||
headerLines[3]!.items = [
|
||
{ text: "表名", normalizedText: "表名", bounds: { x: 99, y: 153, width: 24, height: 12 }, baselineY: 163, hasEol: false },
|
||
{ text: "态", normalizedText: "态", bounds: { x: 463, y: 153, width: 12, height: 12 }, baselineY: 163, hasEol: true },
|
||
];
|
||
const dataFirst = line("首行数据已", 190, 390, 99);
|
||
dataFirst.items = [
|
||
{ text: "首行数据", normalizedText: "首行数据", bounds: { x: 99, y: 180, width: 48, height: 12 }, baselineY: 190, hasEol: false },
|
||
{ text: "已", normalizedText: "已", bounds: { x: 463, y: 180, width: 12, height: 12 }, baselineY: 190, hasEol: true },
|
||
];
|
||
const dataSecond = line("实", 211, 12, 463);
|
||
const dataThird = line("现", 232, 12, 463);
|
||
const group = { tableGroupId: "table-tall-vertical-header" };
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[...headerLines, dataFirst, dataSecond, dataThird]]),
|
||
snapshot("candidate", [[...headerLines, dataFirst, dataSecond, dataThird]]),
|
||
[
|
||
expectation("表名", { index: 0, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 0 }),
|
||
expectation("实现状态", { index: 1, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 1 }),
|
||
expectation("首行数据", { index: 2, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 0 }),
|
||
expectation("已实现", { index: 3, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 1 }),
|
||
],
|
||
);
|
||
|
||
expect(results.map((result) => result.baseline.matched)).toEqual([
|
||
true, true, true, true,
|
||
]);
|
||
expect(results[1]?.baseline.lineTexts).toEqual(["实", "现", "状", "态"]);
|
||
expect(results[3]?.baseline.lineTexts).toEqual(["已", "实", "现"]);
|
||
});
|
||
|
||
it("跨页单元格续行不会为更近列中心跳到下一数据行", () => {
|
||
const pageOne = [
|
||
line("类型 比例", 100, 260, 80),
|
||
line("brea 0.3", 200, 260, 80),
|
||
];
|
||
pageOne[0]!.items = [
|
||
{ text: "类型", normalizedText: "类型", bounds: { x: 80, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: false },
|
||
{ text: "比例", normalizedText: "比例", bounds: { x: 240, y: 90, width: 24, height: 12 }, baselineY: 100, hasEol: true },
|
||
];
|
||
pageOne[1]!.items = [
|
||
{ text: "brea", normalizedText: "brea", bounds: { x: 80, y: 190, width: 32, height: 12 }, baselineY: 200, hasEol: false },
|
||
{ text: "0.3", normalizedText: "0.3", bounds: { x: 240, y: 190, width: 22, height: 12 }, baselineY: 200, hasEol: true },
|
||
];
|
||
const pageTwo = [
|
||
line("重复表头一", 80, 260, 80),
|
||
line("重复表头二", 92, 260, 80),
|
||
line("重复表头三", 104, 260, 80),
|
||
line("重复表头四", 116, 260, 80),
|
||
line("类型 比例", 128, 260, 80),
|
||
line("kfast 0", 140, 260, 80),
|
||
line("lunc 0.4", 180, 260, 80),
|
||
line("h 0", 200, 260, 80),
|
||
];
|
||
pageTwo.forEach((visualLine) => {
|
||
const parts = visualLine.normalizedText.split(" ");
|
||
visualLine.items = parts.length === 1
|
||
? [{ text: parts[0]!, normalizedText: parts[0]!, bounds: { x: 80, y: visualLine.baselineY - 10, width: 60, height: 12 }, baselineY: visualLine.baselineY, hasEol: true }]
|
||
: [
|
||
{ text: parts[0]!, normalizedText: parts[0]!, bounds: { x: 80, y: visualLine.baselineY - 10, width: 40, height: 12 }, baselineY: visualLine.baselineY, hasEol: false },
|
||
{ text: parts[1]!, normalizedText: parts[1]!, bounds: { x: parts[1] === "0" && parts[0] === "lunc" ? 244 : 240, y: visualLine.baselineY - 10, width: 24, height: 12 }, baselineY: visualLine.baselineY, hasEol: true },
|
||
];
|
||
});
|
||
const group = { tableGroupId: "table-cross-page-continuation" };
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [pageOne, pageTwo]),
|
||
snapshot("candidate", [pageOne, pageTwo]),
|
||
[
|
||
expectation("类型", { index: 0, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 0 }),
|
||
expectation("比例", { index: 1, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 1 }),
|
||
expectation("breakfast", { index: 2, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 0 }),
|
||
expectation("0.30", { index: 3, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 1 }),
|
||
expectation("lunch", { index: 4, blockKind: "table-cell", ...group, tableRowIndex: 2, tableColumnIndex: 0 }),
|
||
expectation("0.40", { index: 5, blockKind: "table-cell", ...group, tableRowIndex: 2, tableColumnIndex: 1 }),
|
||
],
|
||
);
|
||
|
||
expect(results.map((result) => result.baseline.matched)).toEqual([
|
||
true, true, true, true, true, true,
|
||
]);
|
||
expect(results[3]?.baseline.lineTexts).toEqual(["0.3", "0"]);
|
||
expect(results[4]?.baseline.lineTexts).toEqual(["lunc", "h"]);
|
||
});
|
||
|
||
it("跨页表格行尾不会吸附后续数据行并跳过下一行长标识符", () => {
|
||
const header = line("资产表注册分层说明状态", 700, 490, 47);
|
||
header.items = [
|
||
{ text: "资产表", normalizedText: "资产表", bounds: { x: 47, y: 690, width: 36, height: 12 }, baselineY: 700, hasEol: false },
|
||
{ text: "注册", normalizedText: "注册", bounds: { x: 183, y: 690, width: 24, height: 12 }, baselineY: 700, hasEol: false },
|
||
{ text: "分层", normalizedText: "分层", bounds: { x: 292, y: 690, width: 24, height: 12 }, baselineY: 700, hasEol: false },
|
||
{ text: "说明", normalizedText: "说明", bounds: { x: 330, y: 690, width: 24, height: 12 }, baselineY: 700, hasEol: false },
|
||
{ text: "状态", normalizedText: "状态", bounds: { x: 499, y: 690, width: 24, height: 12 }, baselineY: 700, hasEol: true },
|
||
];
|
||
const rowOneFirst = line(
|
||
"dwd_snd_terminal_fau(未注册资产D终端故障+抄表明细未实现",
|
||
764,
|
||
490,
|
||
47,
|
||
);
|
||
rowOneFirst.items = [
|
||
{ text: "dwd_snd_terminal_fau", normalizedText: "dwd_snd_terminal_fau", bounds: { x: 47, y: 754, width: 116, height: 12 }, baselineY: 764, hasEol: false },
|
||
{ text: "(未注册资产", normalizedText: "(未注册资产", bounds: { x: 183, y: 754, width: 72, height: 12 }, baselineY: 764, hasEol: false },
|
||
{ text: "D", normalizedText: "D", bounds: { x: 292, y: 754, width: 9, height: 12 }, baselineY: 764, hasEol: false },
|
||
{ text: "终端故障+抄表明细", normalizedText: "终端故障+抄表明细", bounds: { x: 330, y: 754, width: 102, height: 12 }, baselineY: 764, hasEol: false },
|
||
{ text: "未实现", normalizedText: "未实现", bounds: { x: 499, y: 754, width: 36, height: 12 }, baselineY: 764, hasEol: true },
|
||
];
|
||
const rowOneSecond = line("lt_inc名)W(设计", 785, 490, 47);
|
||
rowOneSecond.items = [
|
||
{ text: "lt_inc", normalizedText: "lt_inc", bounds: { x: 47, y: 775, width: 35, height: 12 }, baselineY: 785, hasEol: false },
|
||
{ text: "名)", normalizedText: "名)", bounds: { x: 183, y: 775, width: 24, height: 12 }, baselineY: 785, hasEol: false },
|
||
{ text: "W", normalizedText: "W", bounds: { x: 292, y: 775, width: 13, height: 12 }, baselineY: 785, hasEol: false },
|
||
{ text: "(设计", normalizedText: "(设计", bounds: { x: 499, y: 775, width: 28, height: 12 }, baselineY: 785, hasEol: true },
|
||
];
|
||
|
||
const repeatedHeader = line("表名资产名层设计目标态", 81, 490, 47);
|
||
repeatedHeader.items = [
|
||
{ text: "表名资产名层设计目标", normalizedText: "表名资产名层设计目标", bounds: { x: 47, y: 71, width: 430, height: 12 }, baselineY: 81, hasEol: false },
|
||
{ text: "态", normalizedText: "态", bounds: { x: 515, y: 71, width: 12, height: 12 }, baselineY: 81, hasEol: true },
|
||
];
|
||
const rowOneTail = line("D态)", 115, 490, 292);
|
||
rowOneTail.items = [
|
||
{ text: "D", normalizedText: "D", bounds: { x: 292, y: 105, width: 9, height: 12 }, baselineY: 115, hasEol: false },
|
||
{ text: "态)", normalizedText: "态)", bounds: { x: 499, y: 105, width: 16, height: 12 }, baselineY: 115, hasEol: true },
|
||
];
|
||
const rowTwoFirst = line("dwd_snd_user_account(未注册资产D用户账户未实现", 149, 490, 47);
|
||
rowTwoFirst.items = [
|
||
{ text: "dwd_snd_user_account", normalizedText: "dwd_snd_user_account", bounds: { x: 47, y: 139, width: 116, height: 12 }, baselineY: 149, hasEol: false },
|
||
{ text: "(未注册资产", normalizedText: "(未注册资产", bounds: { x: 183, y: 139, width: 72, height: 12 }, baselineY: 149, hasEol: false },
|
||
{ text: "D", normalizedText: "D", bounds: { x: 292, y: 139, width: 9, height: 12 }, baselineY: 149, hasEol: false },
|
||
{ text: "用户账户", normalizedText: "用户账户", bounds: { x: 330, y: 139, width: 48, height: 12 }, baselineY: 149, hasEol: false },
|
||
{ text: "未实现", normalizedText: "未实现", bounds: { x: 499, y: 139, width: 36, height: 12 }, baselineY: 149, hasEol: true },
|
||
];
|
||
const rowTwoSecond = line("_detail名)W(设计", 171, 490, 47);
|
||
rowTwoSecond.items = [
|
||
{ text: "_detail", normalizedText: "_detail", bounds: { x: 47, y: 161, width: 41, height: 12 }, baselineY: 171, hasEol: false },
|
||
{ text: "名)", normalizedText: "名)", bounds: { x: 183, y: 161, width: 24, height: 12 }, baselineY: 171, hasEol: false },
|
||
{ text: "W", normalizedText: "W", bounds: { x: 292, y: 161, width: 13, height: 12 }, baselineY: 171, hasEol: false },
|
||
{ text: "(设计", normalizedText: "(设计", bounds: { x: 499, y: 161, width: 28, height: 12 }, baselineY: 171, hasEol: true },
|
||
];
|
||
const rowTwoTail = line("D态)", 192, 490, 292);
|
||
rowTwoTail.items = [
|
||
{ text: "D", normalizedText: "D", bounds: { x: 292, y: 182, width: 9, height: 12 }, baselineY: 192, hasEol: false },
|
||
{ text: "态)", normalizedText: "态)", bounds: { x: 499, y: 182, width: 16, height: 12 }, baselineY: 192, hasEol: true },
|
||
];
|
||
const group = { tableGroupId: "table-cross-page-row-tail" };
|
||
const expectations = [
|
||
expectation("资产表", { index: 0, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 0 }),
|
||
expectation("注册", { index: 1, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 1 }),
|
||
expectation("分层", { index: 2, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 2 }),
|
||
expectation("说明", { index: 3, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 3 }),
|
||
expectation("状态", { index: 4, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 4 }),
|
||
expectation("dwd_snd_terminal_fault_inc", { index: 5, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 0 }),
|
||
expectation("(未注册资产名)", { index: 6, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 1 }),
|
||
expectation("DWD", { index: 7, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 2 }),
|
||
expectation("终端故障+抄表明细", { index: 8, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 3 }),
|
||
expectation("未实现(设计态)", { index: 9, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 4 }),
|
||
expectation("dwd_snd_user_account_detail", { index: 10, blockKind: "table-cell", ...group, tableRowIndex: 2, tableColumnIndex: 0 }),
|
||
];
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[header, rowOneFirst, rowOneSecond], [repeatedHeader, rowOneTail, rowTwoFirst, rowTwoSecond, rowTwoTail]]),
|
||
snapshot("candidate", [[header, rowOneFirst, rowOneSecond], [repeatedHeader, rowOneTail, rowTwoFirst, rowTwoSecond, rowTwoTail]]),
|
||
expectations,
|
||
);
|
||
|
||
expect(results.map((result) => result.baseline.matched)).toEqual(
|
||
expectations.map(() => true),
|
||
);
|
||
expect(results[9]?.baseline.visualLines.at(-1)?.bounds.y).toBe(105);
|
||
expect(results[9]?.baseline.lineTexts).toEqual([
|
||
"未实现",
|
||
"(设计",
|
||
"态)",
|
||
]);
|
||
expect(results[10]?.baseline.lineTexts).toEqual([
|
||
"dwd_snd_user_account",
|
||
"_detail",
|
||
]);
|
||
});
|
||
|
||
it("表格首列被拆行时不会被稍后正文中的完整同名词夺走行锚点", () => {
|
||
const header = line("层级 数据性质 具体做法", 100, 440, 78);
|
||
header.items = [
|
||
{ text: "层级", normalizedText: "层级", bounds: { x: 78, y: 90, width: 20, height: 12 }, baselineY: 100, hasEol: false },
|
||
{ text: "数据性质", normalizedText: "数据性质", bounds: { x: 106, y: 90, width: 40, height: 12 }, baselineY: 100, hasEol: false },
|
||
{ text: "具体做法", normalizedText: "具体做法", bounds: { x: 164, y: 90, width: 220, height: 12 }, baselineY: 100, hasEol: true },
|
||
];
|
||
const dwsFirst = line("D 计算 按维度日聚合", 200, 360, 78);
|
||
dwsFirst.items = [
|
||
{ text: "D", normalizedText: "D", bounds: { x: 78, y: 190, width: 8, height: 12 }, baselineY: 200, hasEol: false },
|
||
{ text: "计算", normalizedText: "计算", bounds: { x: 106, y: 190, width: 20, height: 12 }, baselineY: 200, hasEol: false },
|
||
{ text: "按维度日聚合", normalizedText: "按维度日聚合", bounds: { x: 164, y: 190, width: 72, height: 12 }, baselineY: 200, hasEol: true },
|
||
];
|
||
const dwsTail = line("WS", 213, 16, 78);
|
||
const laterBody = line("加工;DWS、ADS 两层是计算", 280, 180, 72);
|
||
const group = { tableGroupId: "table-split-row-anchor" };
|
||
const expectations = [
|
||
expectation("层级", { index: 0, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 0 }),
|
||
expectation("数据性质", { index: 1, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 1 }),
|
||
expectation("具体做法", { index: 2, blockKind: "table-header", ...group, tableRowIndex: 0, tableColumnIndex: 2 }),
|
||
expectation("DWS", { index: 3, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 0 }),
|
||
expectation("计算", { index: 4, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 1 }),
|
||
expectation("按维度日聚合", { index: 5, blockKind: "table-cell", ...group, tableRowIndex: 1, tableColumnIndex: 2 }),
|
||
];
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[header, dwsFirst, dwsTail, laterBody]]),
|
||
snapshot("candidate", [[header, dwsFirst, dwsTail, laterBody]]),
|
||
expectations,
|
||
);
|
||
|
||
expect(results.map((result) => result.baseline.matched)).toEqual([
|
||
true, true, true, true, true, true,
|
||
]);
|
||
expect(results[3]?.baseline.lineTexts).toEqual(["D", "WS"]);
|
||
expect(results[4]?.baseline.firstLineBaselineYPt).toBe(200);
|
||
});
|
||
|
||
it("长标识符在文本层仅缺失少量末尾字形时恢复表头观测", () => {
|
||
const clipped = line("is_workday_requir", 100, 112, 680);
|
||
const [result] = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[clipped]]),
|
||
snapshot("candidate", [[clipped]]),
|
||
[expectation("is_workday_required", {
|
||
blockKind: "table-header",
|
||
tableGroupId: "table-clipped-header",
|
||
tableRowIndex: 0,
|
||
tableColumnIndex: 7,
|
||
})],
|
||
);
|
||
|
||
expect(result?.baseline.matched).toBe(true);
|
||
expect(result?.baseline.matchedCharacterCount).toBe(17);
|
||
expect(result?.baseline.lineTexts).toEqual(["is_workday_requir"]);
|
||
});
|
||
|
||
it("长标识符缺失超过十五个百分点时仍拒绝恢复表头观测", () => {
|
||
const clipped = line("is_workday_req", 100, 96, 680);
|
||
const [result] = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[clipped]]),
|
||
snapshot("candidate", [[clipped]]),
|
||
[expectation("is_workday_required", {
|
||
blockKind: "table-header",
|
||
tableGroupId: "table-over-clipped-header",
|
||
tableRowIndex: 0,
|
||
tableColumnIndex: 7,
|
||
})],
|
||
);
|
||
|
||
expect(result?.baseline.matched).toBe(false);
|
||
});
|
||
|
||
it("双行表头续字不会为更近列坐标跨入下一数据行", () => {
|
||
const headerFirst = line("meal_typ", 100, 64, 80);
|
||
const headerTail = line("e", 118, 8, 92);
|
||
const dataFirst = line("breakfas", 145, 64, 80);
|
||
const dataTail = line("t", 163, 8, 80);
|
||
headerFirst.items = [{ text: "meal_typ", normalizedText: "meal_typ", bounds: { x: 80, y: 90, width: 64, height: 12 }, baselineY: 100, hasEol: true }];
|
||
headerTail.items = [{ text: "e", normalizedText: "e", bounds: { x: 92, y: 108, width: 8, height: 12 }, baselineY: 118, hasEol: true }];
|
||
dataFirst.items = [{ text: "breakfas", normalizedText: "breakfas", bounds: { x: 80, y: 135, width: 64, height: 12 }, baselineY: 145, hasEol: true }];
|
||
dataTail.items = [{ text: "t", normalizedText: "t", bounds: { x: 80, y: 153, width: 8, height: 12 }, baselineY: 163, hasEol: true }];
|
||
const group = { tableGroupId: "table-header-near-next-row", tableColumnIndex: 0 };
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[headerFirst, headerTail, dataFirst, dataTail]]),
|
||
snapshot("candidate", [[headerFirst, headerTail, dataFirst, dataTail]]),
|
||
[
|
||
expectation("meal_type", { index: 0, blockKind: "table-header", ...group, tableRowIndex: 0 }),
|
||
expectation("breakfast", { index: 1, blockKind: "table-cell", ...group, tableRowIndex: 1 }),
|
||
],
|
||
);
|
||
|
||
expect(results.map((result) => result.baseline.matched)).toEqual([
|
||
true, true,
|
||
]);
|
||
expect(results[0]?.baseline.lineTexts).toEqual(["meal_typ", "e"]);
|
||
expect(results[1]?.baseline.lineTexts).toEqual(["breakfas", "t"]);
|
||
});
|
||
|
||
it("新表首列保持最早视觉行的最左前缀而不被稍后更长前缀抢占", () => {
|
||
const visualLines = [
|
||
line("mea", 100, 30, 100),
|
||
line("meal", 120, 40, 400),
|
||
line("l_type", 140, 50, 100),
|
||
];
|
||
const paragraphs = [
|
||
expectation("meal_type", {
|
||
index: 0,
|
||
blockKind: "table-header",
|
||
tableGroupId: "table-leftmost-prefix",
|
||
tableRowIndex: 0,
|
||
tableColumnIndex: 0,
|
||
}),
|
||
];
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [visualLines]),
|
||
snapshot("candidate", [visualLines]),
|
||
paragraphs,
|
||
);
|
||
|
||
expect(results[0]?.baseline.matched).toBe(true);
|
||
expect(results[0]?.candidate.matched).toBe(true);
|
||
expect(results[0]?.baseline.lineTexts).toEqual(["mea", "l_type"]);
|
||
});
|
||
|
||
it("按字符类别估算合并文本项中的有序列表正文边界", () => {
|
||
const listLine = line("3.系统联调", 220, 100, 100);
|
||
listLine.items = [
|
||
{
|
||
text: "3. 系统联调",
|
||
normalizedText: "3. 系统联调",
|
||
bounds: { x: 100, y: 210, width: 100, height: 12 },
|
||
baselineY: 220,
|
||
fontFamily: "Test Serif",
|
||
hasEol: true,
|
||
},
|
||
];
|
||
const [result] = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[listLine]]),
|
||
snapshot("candidate", [[listLine]]),
|
||
[expectation("系统联调", { blockKind: "list-item" })],
|
||
);
|
||
|
||
expect(result?.baseline.visualLines[0]?.bounds.x).toBeCloseTo(123.48, 1);
|
||
expect(result?.baseline.visualLines[0]?.bounds.width).toBeCloseTo(76.52, 1);
|
||
});
|
||
|
||
it("从被归一化行剥离的项目符号后开始裁剪正文", () => {
|
||
const listLine = line("系统联调", 220, 100, 100);
|
||
listLine.items = [
|
||
{
|
||
text: "• 系统联调",
|
||
normalizedText: "• 系统联调",
|
||
bounds: { x: 100, y: 210, width: 100, height: 12 },
|
||
baselineY: 220,
|
||
fontFamily: "Test Serif",
|
||
hasEol: true,
|
||
},
|
||
];
|
||
const [result] = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[listLine]]),
|
||
snapshot("candidate", [[listLine]]),
|
||
[expectation("系统联调", { blockKind: "list-item" })],
|
||
);
|
||
|
||
expect(result?.baseline.visualLines[0]?.bounds.x).toBeGreaterThan(100);
|
||
expect(result?.baseline.visualLines[0]?.bounds.width).toBeLessThan(100);
|
||
expect(result?.baseline.lineTexts).toEqual(["系统联调"]);
|
||
});
|
||
|
||
it("跳过被无关字符打断的相似文本并匹配后续完整段落", () => {
|
||
const [result] = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[
|
||
line("甲X乙", 180),
|
||
line("甲乙", 220),
|
||
]]),
|
||
snapshot("candidate", [[
|
||
line("甲X乙", 180),
|
||
line("甲乙", 220),
|
||
]]),
|
||
[expectation("甲乙")],
|
||
);
|
||
|
||
expect(result?.status).toBe("passed");
|
||
expect(result?.baseline.firstLineBaselineYPt).toBe(220);
|
||
expect(result?.candidate.firstLineBaselineYPt).toBe(220);
|
||
});
|
||
|
||
it("连续段落可以自然跨越多个 PDF 视觉行", () => {
|
||
const [result] = comparePdfEditableParagraphLayouts(
|
||
snapshot("baseline", [[line("甲乙", 180), line("丙丁", 204)]]),
|
||
snapshot("candidate", [[line("甲乙", 180), line("丙丁", 204)]]),
|
||
[expectation("甲乙丙丁")],
|
||
);
|
||
|
||
expect(result?.status).toBe("passed");
|
||
expect(result?.baseline.lineTexts).toEqual(["甲乙", "丙丁"]);
|
||
});
|
||
|
||
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 baseline = snapshot("baseline", [[
|
||
line("甲乙丙丁", 220),
|
||
line("戊己庚辛", 244),
|
||
]]);
|
||
const candidate = snapshot("candidate", [[
|
||
line("甲乙丙丁戊己", 220),
|
||
line("庚辛", 244),
|
||
]]);
|
||
const [result] = comparePdfEditableParagraphLayouts(
|
||
baseline,
|
||
candidate,
|
||
[expectation("甲乙丙丁戊己庚辛", { blockKind: "paragraph" })],
|
||
);
|
||
|
||
expect(result?.status).toBe("warning");
|
||
expect(result?.issues).toContainEqual(
|
||
expect.objectContaining({
|
||
code: "PARAGRAPH_LINE_BREAK_MISMATCH",
|
||
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("拒绝正文内容回流到独立封面页", () => {
|
||
const baseline = snapshot("baseline", [
|
||
[line("独立封面", 200)],
|
||
[line("第一章项目概述", 100)],
|
||
]);
|
||
const candidate = snapshot("candidate", [
|
||
[line("独立封面", 200), line("第一章项目概述", 700)],
|
||
[],
|
||
]);
|
||
const pageSemantics = [
|
||
{ physicalPageNumber: 1, kind: "cover" as const },
|
||
{ physicalPageNumber: 2, kind: "body-first" as const },
|
||
];
|
||
const [result] = comparePdfEditableParagraphLayouts(
|
||
baseline,
|
||
candidate,
|
||
[expectation("第一章项目概述")],
|
||
pageSemantics,
|
||
pageSemantics,
|
||
);
|
||
expect(result?.status).toBe("failed");
|
||
expect(result?.issues).toContainEqual(
|
||
expect.objectContaining({
|
||
code: "COVER_LAYOUT_MISMATCH",
|
||
severity: "failure",
|
||
}),
|
||
);
|
||
});
|
||
|
||
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",
|
||
);
|
||
});
|
||
|
||
it("表格段落按各自列左锚点归一化横向几何", () => {
|
||
const group = {
|
||
tableGroupId: "table-column-relative-geometry",
|
||
blockKind: "table-cell" as const,
|
||
};
|
||
const baseline = snapshot("baseline", [[
|
||
itemLine("甲项", 120, 24, 80),
|
||
itemLine("甲值", 120, 24, 180),
|
||
itemLine("乙项", 150, 24, 80),
|
||
itemLine("乙值", 150, 24, 180),
|
||
]]);
|
||
const candidate = snapshot("candidate", [[
|
||
itemLine("甲项", 120, 24, 79),
|
||
itemLine("甲值", 120, 24, 177.5),
|
||
itemLine("乙项", 150, 24, 79),
|
||
itemLine("乙值", 150, 24, 177.5),
|
||
]]);
|
||
const results = comparePdfEditableParagraphLayouts(
|
||
baseline,
|
||
candidate,
|
||
[
|
||
expectation("甲项", { index: 0, ...group, tableRowIndex: 0, tableColumnIndex: 0 }),
|
||
expectation("甲值", { index: 1, ...group, tableRowIndex: 0, tableColumnIndex: 1 }),
|
||
expectation("乙项", { index: 2, ...group, tableRowIndex: 1, tableColumnIndex: 0 }),
|
||
expectation("乙值", { index: 3, ...group, tableRowIndex: 1, tableColumnIndex: 1 }),
|
||
],
|
||
);
|
||
|
||
expect(results.map((result) => result.status)).toEqual([
|
||
"passed",
|
||
"passed",
|
||
"passed",
|
||
"passed",
|
||
]);
|
||
});
|
||
});
|