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 { return { index: 0, text, role: "body", section: "body", ...overrides, }; } describe("PDF 内容感知段落版式门禁", () => { it("允许正文段落整体移动到下一物理页", () => { const baseline = snapshot("baseline", [ [line("建立月度调度机制及时协调", 700), line("解决项目中的问题", 728)], [], ]); const candidate = snapshot("candidate", [ [], [line("建立月度调度机制及时协调", 100), line("解决项目中的问题", 128)], ]); const [result] = comparePdfEditableParagraphLayouts( baseline, candidate, [expectation("建立月度调度机制及时协调解决项目中的问题")], ); expect(result?.status).toBe("passed"); expect(result?.baseline.pageNumbers).toEqual([1]); expect(result?.candidate.pageNumbers).toEqual([2]); }); it("拒绝标题从一行变成两行", () => { const baseline = snapshot("baseline", [ [line("智慧园区一体化平台建设项目", 220, 250)], ]); const candidate = snapshot("candidate", [ [line("智慧园区一体化平台", 220, 190), line("建设项目", 250, 60)], ]); const [result] = comparePdfEditableParagraphLayouts( baseline, candidate, [ expectation("智慧园区一体化平台建设项目", { role: "title", section: "cover", styleId: "MdProjectReportProjectName", }), ], ); expect(result?.status).toBe("failed"); expect(result?.issues.map((issue) => issue.code)).toContain( "TEXT_BLOCK_LINE_COUNT_MISMATCH", ); }); it("允许长正文仅有极短末行的跨引擎换行差异", () => { const text = "数字化管理部门负责项目统筹技术审查过程监督和验收管理需求单位负责业务需求确认试运行和应用推广"; const baseline = snapshot("baseline", [ [line(text.slice(0, -2), 220, 680), line(text.slice(-2), 244, 28)], ]); const candidate = snapshot("candidate", [[line(text, 220, 695)]]); const [result] = comparePdfEditableParagraphLayouts( baseline, candidate, [expectation(text)], ); expect(result?.status).toBe("warning"); expect(result?.issues).toEqual([ expect.objectContaining({ code: "BODY_LINE_COUNT_NEAR_BOUNDARY", severity: "warning", }), ]); }); it("拒绝短正文或非极短末行的行数差异", () => { const text = "项目建设内容需要严格控制质量进度投资安全风险"; const baseline = snapshot("baseline", [ [line(text.slice(0, -5), 220), line(text.slice(-5), 244)], ]); const candidate = snapshot("candidate", [[line(text, 220)]]); const [result] = comparePdfEditableParagraphLayouts( baseline, candidate, [expectation(text)], ); expect(result?.status).toBe("failed"); expect(result?.issues.map((issue) => issue.code)).toContain( "TEXT_BLOCK_LINE_COUNT_MISMATCH", ); }); it("拒绝封面内容漂移到正文页", () => { const baseline = snapshot("baseline", [ [line("可行性研究报告", 300, 180)], [], ]); const candidate = snapshot("candidate", [ [], [line("可行性研究报告", 100, 180)], ]); const [result] = comparePdfEditableParagraphLayouts( baseline, candidate, [ expectation("可行性研究报告", { role: "title", section: "cover", styleId: "MdProjectReportTitle", }), ], ); expect(result?.status).toBe("failed"); expect(result?.issues.map((issue) => issue.code)).toContain( "COVER_LAYOUT_MISMATCH", ); }); it("忽略 PDF 文本提取器失真的单行宽度但保留诊断值", () => { const baseline = snapshot("baseline", [ [line("数据治理平台建设项目", 300, 224)], ]); const candidate = snapshot("candidate", [ [line("数据治理平台建设项目", 300, 210)], ]); const [result] = comparePdfEditableParagraphLayouts( baseline, candidate, [ expectation("数据治理平台建设项目", { role: "title", section: "cover", styleId: "MdProjectReportTitle", }), ], ); expect(result?.status).toBe("passed"); expect(result?.baseline.maximumLineWidthPt).toBe(224); expect(result?.candidate.maximumLineWidthPt).toBe(210); }); it("允许封面字形基线在 3pt 内波动", () => { const baseline = snapshot("baseline", [ [line("数据治理平台建设项目", 300, 224)], ]); const candidate = snapshot("candidate", [ [line("数据治理平台建设项目", 302.5, 224)], ]); const [result] = comparePdfEditableParagraphLayouts( baseline, candidate, [ expectation("数据治理平台建设项目", { role: "title", section: "cover", styleId: "MdProjectReportTitle", }), ], ); expect(result?.status).toBe("passed"); }); it("拒绝封面字形基线偏移超过 6pt", () => { const baseline = snapshot("baseline", [ [line("数据治理平台建设项目", 300, 224)], ]); const candidate = snapshot("candidate", [ [line("数据治理平台建设项目", 306.1, 224)], ]); const [result] = comparePdfEditableParagraphLayouts( baseline, candidate, [ expectation("数据治理平台建设项目", { role: "title", section: "cover", styleId: "MdProjectReportTitle", }), ], ); expect(result?.status).toBe("failed"); expect(result?.issues.map((issue) => issue.code)).toContain( "COVER_LAYOUT_MISMATCH", ); }); it("允许封面文本提取框横向偏差在 3pt 内波动", () => { const baseline = snapshot("baseline", [ [line("数据治理平台建设项目", 300, 224, 198)], ]); const candidate = snapshot("candidate", [ [line("数据治理平台建设项目", 300, 224, 200.5)], ]); const [result] = comparePdfEditableParagraphLayouts( baseline, candidate, [ expectation("数据治理平台建设项目", { role: "title", section: "cover", styleId: "MdProjectReportTitle", }), ], ); expect(result?.status).toBe("passed"); }); it("正文文本横向偏差仍严格限制为 2pt", () => { const baseline = snapshot("baseline", [ [line("项目建设内容", 300, 120, 72)], ]); const candidate = snapshot("candidate", [ [line("项目建设内容", 300, 120, 74.5)], ]); const [result] = comparePdfEditableParagraphLayouts( baseline, candidate, [expectation("项目建设内容")], ); expect(result?.status).toBe("failed"); expect(result?.issues.map((issue) => issue.code)).toContain( "PARAGRAPH_LINE_GEOMETRY_MISMATCH", ); }); });