Files
MorphDoc/packages/document-visual-diff/tests/paragraph-layout.test.ts
T
SkyJourney 2c5c1bd317 release: 发布 v0.6.1 DOCX 视觉一致性修复
新增通用 CSS 到 OOXML 翻译修复,统一字体、字距、精确行距、段落、列表、表格、引用、代码块与行内代码连续性,不引入按主题 ID 分支。

新增 MdTP Mono 并统一 Serif、Sans、Mono 三字体包的 Chromium 与 DOCX 使用链;字体声明、嵌入部件和 Word/WPS 实际采用均进入硬门禁。

重建封面整页及正文语义块视觉差分,14 套主题、纵横两个方向、五组页边距共 140 个真实场景全部通过,阻断失败和诊断失败均为零。

源码服务、Docker Web API 与实际安装 Desktop 的 red-briefing 导出均包含 5 个字体部件;Word/WPS 原生渲染和逐页复核通过。修复 Docker 构建上下文与运行层复用软链接,并完善 v0.6.1 版本、发行说明和发布归集。

验证:npm test(116 个文件、616 项测试)、npm run typecheck、npm run build、git diff --check 全部通过。Desktop 安装器与 ZIP、Docker v0.6.1 镜像已生成;Windows 产物仍为未签名内部发行。
2026-08-04 10:30:44 +08:00

418 lines
12 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 snapshot(
label: string,
pages: PdfTextLineSnapshot[][],
): PdfDocumentSnapshot {
return {
schemaVersion: 1,
source: { kind: "custom", label },
sha256: label.padEnd(64, "0").slice(0, 64),
pageCount: pages.length,
contentText: pages.flat().map((item) => item.normalizedText).join(""),
pages: pages.map((lines, index) => ({
pageNumber: index + 1,
widthPt: 595,
heightPt: 842,
rotation: 0,
items: [],
lines,
contentText: lines.map((item) => item.normalizedText).join(""),
})),
};
}
function expectation(
text: string,
overrides: Partial<EditableParagraphExpectation> = {},
): EditableParagraphExpectation {
return {
index: 0,
text,
role: "body",
section: "body",
...overrides,
};
}
describe("PDF 内容感知段落版式门禁", () => {
it("同一物理行中的表格单元格分别保留自己的视觉边界", () => {
const 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 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(117.18, 1);
expect(result?.baseline.visualLines[0]?.bounds.width).toBeCloseTo(82.82, 1);
});
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",
);
});
});