fix: 重建DOCX视觉一致性门禁
This commit is contained in:
+31
-5
@@ -1,6 +1,6 @@
|
|||||||
# 墨呈进度
|
# 墨呈进度
|
||||||
|
|
||||||
最后更新:2026-08-02
|
最后更新:2026-08-03
|
||||||
|
|
||||||
## 1. 当前概况
|
## 1. 当前概况
|
||||||
|
|
||||||
@@ -68,6 +68,14 @@ Python;左侧编辑器计划升级为 CodeMirror 6,并通过项目自有命
|
|||||||
支持基础工具栏及后续 Front Matter、ECharts YAML 工具扩展。完整设计与
|
支持基础工具栏及后续 Front Matter、ECharts YAML 工具扩展。完整设计与
|
||||||
验收门禁见 [v0.6.0 设计文档](V0.6.0_DESIGN.md)。
|
验收门禁见 [v0.6.0 设计文档](V0.6.0_DESIGN.md)。
|
||||||
|
|
||||||
|
`v0.6.1` 已冻结为 DOCX 视觉一致性和发布门禁修复版本。发布后复核确认
|
||||||
|
`v0.6.0` 的 14 主题矩阵主要验证 CSS 槽位、OOXML 结构和样式存在性,
|
||||||
|
六个视觉场景只覆盖五套主题;视觉报告中的像素、墨迹 IoU 和边缘 IoU
|
||||||
|
failure 还被外层白名单过滤,导致报告失败时总门禁仍可通过。`v0.6.1`
|
||||||
|
要求四套独立封面执行 Chromium、Word、WPS 整页硬门禁,正文改为跨页
|
||||||
|
无关的语义块局部视觉门禁,并对字体真实解析、嵌入和发布后三条生产链路
|
||||||
|
建立阻断验收。完整范围见 [v0.6.1 设计文档](V0.6.1_DESIGN.md)。
|
||||||
|
|
||||||
`v0.6.0` 阶段 2 已完成 Pandoc 运行时探查:固定 `3.9.0.2`,官方
|
`v0.6.0` 阶段 2 已完成 Pandoc 运行时探查:固定 `3.9.0.2`,官方
|
||||||
Windows ZIP 和 Linux amd64 tarball 的 SHA-256 均已核验;Linux 静态程序
|
Windows ZIP 和 Linux amd64 tarball 的 SHA-256 均已核验;Linux 静态程序
|
||||||
可直接运行于 `node:22-bookworm-slim`。Desktop 安装包预计增加约
|
可直接运行于 `node:22-bookworm-slim`。Desktop 安装包预计增加约
|
||||||
@@ -1460,9 +1468,9 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
|
|||||||
Server HTTP、Chromium PDF 与生产 DOCX 矩阵;
|
Server HTTP、Chromium PDF 与生产 DOCX 矩阵;
|
||||||
- 阶段 12D-R4 媒体专项:已完成稳定 PNG 布局、严格 DrawingML、五媒体
|
- 阶段 12D-R4 媒体专项:已完成稳定 PNG 布局、严格 DrawingML、五媒体
|
||||||
Word/WPS 编辑互存、隔离逐页视觉比较和题注居中门禁;
|
Word/WPS 编辑互存、隔离逐页视觉比较和题注居中门禁;
|
||||||
- 阶段 12D-R4 布局视觉矩阵:已完成六组主题、纸张、方向与页边距组合的
|
- 阶段 12D-R4 布局视觉矩阵:曾记录为完成六组 Chromium/Word/WPS
|
||||||
Chromium/Word/WPS 元素级严格门禁,并重跑 14 套主题生产矩阵;分页流
|
元素级严格门禁;`v0.6.1` 发布后复核确认该矩阵只覆盖五套主题,且外层
|
||||||
差异保留为诊断项,不再误判为样式失败;
|
过滤了像素、墨迹 IoU 和边缘 IoU failure,因此不构成有效严格门禁;
|
||||||
- 发布收口阶段 A:已完成 v0.6.0 版本、墨呈/MorphDoc 品牌、NSIS 名称、
|
- 发布收口阶段 A:已完成 v0.6.0 版本、墨呈/MorphDoc 品牌、NSIS 名称、
|
||||||
文件关联兼容和旧版用户数据迁移;
|
文件关联兼容和旧版用户数据迁移;
|
||||||
- 发布收口阶段 B:已完成 Desktop 固定 Pandoc 最小运行时的清单、下载
|
- 发布收口阶段 B:已完成 Desktop 固定 Pandoc 最小运行时的清单、下载
|
||||||
@@ -1473,10 +1481,28 @@ ECharts 第二阶段浏览器与 PDF 验证结果:
|
|||||||
镜像封包、部署 ZIP、清单与校验和规则,并将正式字体纳入 Git LFS;
|
镜像封包、部署 ZIP、清单与校验和规则,并将正式字体纳入 Git LFS;
|
||||||
- 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。
|
- 阶段 13:完成 Word/WPS 双向互存、外部主题兼容、体积和正式发布验收。
|
||||||
|
|
||||||
|
### 阶段二:v0.6.1 DOCX 视觉一致性修复
|
||||||
|
|
||||||
|
- 阶段 A:已完成。修复所有 failure 的发布阻断聚合,补齐四套独立封面
|
||||||
|
场景,并禁止正文回流封面页;四套封面真实 Word/WPS 复验均已被现有
|
||||||
|
视觉差异正确阻断;
|
||||||
|
- 阶段 B:已完成。正文段落按内容顺序映射为与物理页号无关的视觉块,
|
||||||
|
保留实际行坐标和 PDF.js 字体分类诊断,逐行裁剪局部栅格并输出叠加图、
|
||||||
|
热力图;正文整页栅格降为诊断,封面仍为整页硬门禁。单元测试 47 项、
|
||||||
|
全项目类型检查和生产构建通过;真实 GitHub 紧凑 Letter 纵向场景观测
|
||||||
|
22 个正文块、输出 102 张失败局部热力图并正确阻断。PDF.js 泛化字体名
|
||||||
|
不作为字体硬门禁,真实字体解析留在阶段 D;
|
||||||
|
- 阶段 C:将语义块门禁扩展为 14 套内置主题 × 纵横两个方向 × 主题默认、
|
||||||
|
标准、紧凑、宽松、非对称装订五组页边距,共 140 个排版场景;主题
|
||||||
|
默认链路即使与显式数值相同也不得去重;
|
||||||
|
- 阶段 D:修复主题封面、块元素样式和字体解析、嵌入、回退;
|
||||||
|
- 阶段 E:完成源码服务、Docker Web API、Desktop 安装版发布后复验;
|
||||||
|
- 阶段 F:完成 `v0.6.1` 版本、发行说明、发布产物、发布提交和标签。
|
||||||
|
|
||||||
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
|
每个阶段验收通过后创建一个独立提交,再进入下一阶段。当前阶段不得混入
|
||||||
后续阶段的功能实现。
|
后续阶段的功能实现。
|
||||||
|
|
||||||
### 阶段二:后续版本扩展
|
### 阶段三:后续版本扩展
|
||||||
|
|
||||||
- Front Matter 可视化编辑工具;
|
- Front Matter 可视化编辑工具;
|
||||||
- ECharts YAML 可视化配置工具;
|
- ECharts YAML 可视化配置工具;
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# v0.6.1 DOCX 视觉一致性与发布门禁修复设计
|
||||||
|
|
||||||
|
状态:范围已冻结,阶段 A、B 已完成,等待阶段 C。
|
||||||
|
|
||||||
|
## 1. 版本目标
|
||||||
|
|
||||||
|
`v0.6.1` 是针对 `v0.6.0` DOCX 导出质量的维护版本,不新增与 DOCX
|
||||||
|
无关的产品功能。本版本必须完成:
|
||||||
|
|
||||||
|
1. 修复视觉报告失败但总发布门禁仍通过的判定漏洞;
|
||||||
|
2. 对四套独立封面主题建立 Chromium、Word、WPS 整页严格门禁;
|
||||||
|
3. 对 14 套内置主题建立与正文分页位置无关的语义块局部视觉门禁;
|
||||||
|
4. 修复主题样式、封面布局和字体解析、嵌入、回退问题;
|
||||||
|
5. 对源码服务、Docker Web API 和 Desktop 安装版执行发布后复验。
|
||||||
|
|
||||||
|
本版本不要求 Chromium、Word 和 WPS 的正文总页数或分页边界完全一致,
|
||||||
|
但不允许以排版引擎不同为理由放过封面、字体或语义块样式退化。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["同一 Markdown、主题与配置"] --> B["生产 Chromium PDF"]
|
||||||
|
A --> C["生产 DOCX"]
|
||||||
|
C --> D["Word 原生渲染"]
|
||||||
|
C --> E["WPS 原生渲染"]
|
||||||
|
B --> F["封面整页严格比较"]
|
||||||
|
D --> F
|
||||||
|
E --> F
|
||||||
|
B --> G["正文语义块匹配"]
|
||||||
|
D --> G
|
||||||
|
E --> G
|
||||||
|
F --> H["发布硬门禁"]
|
||||||
|
G --> H
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 问题基线
|
||||||
|
|
||||||
|
`v0.6.0` 复核确认存在以下门禁缺口:
|
||||||
|
|
||||||
|
- 视觉报告中的像素差异、墨迹 IoU、边缘 IoU 等 failure 级问题未进入
|
||||||
|
外层发布阻断集合;
|
||||||
|
- 六个布局视觉场景只覆盖五套主题,未覆盖全部 14 套主题;
|
||||||
|
- 四套独立封面只覆盖其中两套;
|
||||||
|
- “独立封面”主要检查 OOXML 分节、页眉页脚、页码重启和字段存在,
|
||||||
|
不能代表视觉一致;
|
||||||
|
- 14 主题样式门禁主要检查 CSS 槽位、令牌和 Word 样式 ID 存在,不能
|
||||||
|
代表最终 Word/WPS 呈现一致;
|
||||||
|
- 字体名称写入 `fontTable.xml`、转换无 warning,均不能证明字体已经解析、
|
||||||
|
嵌入并被 Office 实际使用;
|
||||||
|
- `red-briefing` 在门禁、Docker Web 和用户实际产物中均为零嵌入字体,
|
||||||
|
仍以零 warning 通过。
|
||||||
|
|
||||||
|
## 3. 验收模型
|
||||||
|
|
||||||
|
### 3.1 封面整页硬门禁
|
||||||
|
|
||||||
|
适用于 `formal-feasibility`、`tender-business-blue`、`tender-blind` 和
|
||||||
|
`tender-classic`:
|
||||||
|
|
||||||
|
- 封面必须独占一个物理页;
|
||||||
|
- 所有封面字段只能出现在封面页,正文不得回流到封面页;
|
||||||
|
- 封面页不显示正文页眉、页脚和页码;
|
||||||
|
- 字体、字号、字重、颜色、对齐、位置、边框、背景和装饰线必须匹配;
|
||||||
|
- 整页像素、墨迹 IoU、边缘 IoU 任一 failure 都阻断发布;
|
||||||
|
- Word 和 WPS 都必须通过,不允许只以二者相互接近替代 Chromium 基线。
|
||||||
|
|
||||||
|
### 3.2 正文语义块门禁
|
||||||
|
|
||||||
|
正文按稳定内容顺序和语义角色匹配,不按物理页号配对。允许语义块移动到
|
||||||
|
另一页,但块自身必须保持视觉一致:
|
||||||
|
|
||||||
|
| 语义块 | 必须比较 |
|
||||||
|
| --- | --- |
|
||||||
|
| 标题、正文 | 字体、字号、字重、颜色、行高、缩进、段距、对齐 |
|
||||||
|
| 引用、提示框 | 背景、边框、内边距、文字样式 |
|
||||||
|
| 代码块 | 等宽字体、字号、背景、边框、内边距、换行 |
|
||||||
|
| 列表 | 编号或项目符号、缩进、悬挂距离、文字样式 |
|
||||||
|
| 表格 | 总宽度、列宽、边框、底纹、单元格内边距、表头与文字样式 |
|
||||||
|
| 图片与图表 | 尺寸、宽高比、清晰度、对齐和题注 |
|
||||||
|
|
||||||
|
正文整页栅格差异只作为诊断,不作为最终样式结论;没有完成语义块局部比较
|
||||||
|
的主题不得标记为视觉通过。
|
||||||
|
|
||||||
|
### 3.3 字体硬门禁
|
||||||
|
|
||||||
|
- 记录 Chromium、DOCX 字体表、嵌入字体部件、Word PDF 和 WPS PDF 的
|
||||||
|
实际字体;
|
||||||
|
- 需要跨环境稳定的字体必须来自许可证允许分发的字体包;
|
||||||
|
- 应嵌入字体但字体部件为零时直接失败;
|
||||||
|
- 字体包未应用、字体回退或 Office 未使用目标字体时必须产生阻断错误;
|
||||||
|
- 字体名称存在但没有字体二进制不得视为通过。
|
||||||
|
|
||||||
|
### 3.4 发布链路门禁
|
||||||
|
|
||||||
|
同一验收输入必须覆盖:
|
||||||
|
|
||||||
|
1. 源码测试服务;
|
||||||
|
2. 正式 Docker Web API;
|
||||||
|
3. 正式 Desktop 安装版。
|
||||||
|
|
||||||
|
任一底层报告状态为 `failed` 时,总门禁必须失败。只有已经证明属于合法
|
||||||
|
正文自然分页流动的问题才能降级为 warning;封面、字体、内容、语义块
|
||||||
|
样式和媒体失败不得降级。
|
||||||
|
|
||||||
|
### 3.5 全量配置矩阵
|
||||||
|
|
||||||
|
发布门禁不是少量代表用例,而是固定执行 `14 × 2 × 5 = 140` 个排版
|
||||||
|
场景:
|
||||||
|
|
||||||
|
- 14 套内置主题;
|
||||||
|
- 纵向、横向两个纸张方向;
|
||||||
|
- 主题默认、标准、紧凑、宽松和非对称装订五组页边距。
|
||||||
|
|
||||||
|
“主题默认”必须使用 `marginMode: theme`,验证主题清单中的真实默认值;
|
||||||
|
即使解析后的数值与某组显式边距相同,也不得去重。其余四组使用显式
|
||||||
|
`custom` 配置,验证配置覆盖链路。每个场景生成 Chromium PDF、DOCX、
|
||||||
|
Word PDF 和 WPS PDF,并在报告中保留主题、方向、边距场景、导出链和
|
||||||
|
渲染引擎维度,不允许用汇总绿灯掩盖单个失败。
|
||||||
|
|
||||||
|
## 4. 实施阶段
|
||||||
|
|
||||||
|
1. 阶段 A:修复 failure 聚合策略,补齐四套封面场景和封面正文隔离;
|
||||||
|
2. 阶段 B:建立跨页无关的语义块观察、匹配、局部栅格和样式比较协议;
|
||||||
|
3. 阶段 C:建立 14 主题、2 个方向、5 组页边距的 140 场景全量语义块
|
||||||
|
矩阵和版本化门限;
|
||||||
|
4. 阶段 D:逐主题修复封面、正文块样式和字体嵌入;
|
||||||
|
5. 阶段 E:完成源码、Docker Web、Desktop 安装版发布后复验;
|
||||||
|
6. 阶段 F:更新版本、发行说明、发布产物、发布提交和 annotated tag。
|
||||||
|
|
||||||
|
每个阶段必须先提交真实失败基线,再修复到通过。不得通过删除用例、提高
|
||||||
|
门限、过滤 failure 或把 failure 改名为 warning 取得绿灯。
|
||||||
|
|
||||||
|
## 5. v0.6.1 发布条件
|
||||||
|
|
||||||
|
- 14 套主题在纵向、横向和五组页边距下全部生成真实 Chromium PDF、
|
||||||
|
DOCX、Word PDF 和 WPS PDF;
|
||||||
|
- 四套独立封面全部通过整页严格门禁;
|
||||||
|
- 14 套主题声明支持的语义块全部通过局部视觉门禁;
|
||||||
|
- 字体解析、嵌入和实际使用结果符合主题预期;
|
||||||
|
- 源码、Docker Web 和 Desktop 安装版结果一致;
|
||||||
|
- 单元测试、类型检查、生产构建和 `git diff --check` 通过;
|
||||||
|
- 发布报告中不存在被忽略的 failure;
|
||||||
|
- 完成真实 Word/WPS 逐页人工复核后才能发布。
|
||||||
@@ -6,6 +6,7 @@ export * from "./paragraph-layout.js";
|
|||||||
export * from "./page-semantics.js";
|
export * from "./page-semantics.js";
|
||||||
export * from "./raster.js";
|
export * from "./raster.js";
|
||||||
export * from "./report.js";
|
export * from "./report.js";
|
||||||
|
export * from "./semantic-block.js";
|
||||||
export * from "./snapshot.js";
|
export * from "./snapshot.js";
|
||||||
export * from "./text.js";
|
export * from "./text.js";
|
||||||
export * from "./types.js";
|
export * from "./types.js";
|
||||||
|
|||||||
@@ -113,6 +113,17 @@ function observeParagraph(
|
|||||||
Array.from(lines[index]?.normalizedText ?? "").length ===
|
Array.from(lines[index]?.normalizedText ?? "").length ===
|
||||||
(matchedByLine.get(index)?.length ?? 0),
|
(matchedByLine.get(index)?.length ?? 0),
|
||||||
);
|
);
|
||||||
|
const visualLines = matchedLines.map((line) => ({
|
||||||
|
pageNumber: line.pageNumber,
|
||||||
|
bounds: line.line.bounds,
|
||||||
|
fontFamilies: [
|
||||||
|
...new Set(
|
||||||
|
line.line.items.flatMap((item) =>
|
||||||
|
item.fontFamily ? [item.fontFamily] : [],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
].sort(),
|
||||||
|
}));
|
||||||
const matched = matchedCharacterCount === expectedCharacters.length;
|
const matched = matchedCharacterCount === expectedCharacters.length;
|
||||||
if (matched) {
|
if (matched) {
|
||||||
cursor.lineIndex = localCursor.lineIndex;
|
cursor.lineIndex = localCursor.lineIndex;
|
||||||
@@ -146,6 +157,7 @@ function observeParagraph(
|
|||||||
? {}
|
? {}
|
||||||
: { averageBaselineGapPt }),
|
: { averageBaselineGapPt }),
|
||||||
exclusiveGeometry,
|
exclusiveGeometry,
|
||||||
|
visualLines,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +204,8 @@ function compareParagraph(
|
|||||||
expectation: EditableParagraphExpectation,
|
expectation: EditableParagraphExpectation,
|
||||||
baseline: PdfParagraphLayoutObservation,
|
baseline: PdfParagraphLayoutObservation,
|
||||||
candidate: PdfParagraphLayoutObservation,
|
candidate: PdfParagraphLayoutObservation,
|
||||||
|
baselineCoverPageCount: number,
|
||||||
|
candidateCoverPageCount: number,
|
||||||
): VisualDiffIssue[] {
|
): VisualDiffIssue[] {
|
||||||
const issues: VisualDiffIssue[] = [];
|
const issues: VisualDiffIssue[] = [];
|
||||||
if (!baseline.matched || !candidate.matched) {
|
if (!baseline.matched || !candidate.matched) {
|
||||||
@@ -299,6 +313,27 @@ function compareParagraph(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else if (baselineCoverPageCount > 0 || candidateCoverPageCount > 0) {
|
||||||
|
const baselineOutsideCover = baseline.pageNumbers.every(
|
||||||
|
(pageNumber) => pageNumber > baselineCoverPageCount,
|
||||||
|
);
|
||||||
|
const candidateOutsideCover = candidate.pageNumbers.every(
|
||||||
|
(pageNumber) => pageNumber > candidateCoverPageCount,
|
||||||
|
);
|
||||||
|
if (!baselineOutsideCover || !candidateOutsideCover) {
|
||||||
|
issues.push({
|
||||||
|
code: "COVER_LAYOUT_MISMATCH",
|
||||||
|
severity: "failure",
|
||||||
|
message: `第 ${expectation.index + 1} 个正文段落回流到独立封面页`,
|
||||||
|
details: {
|
||||||
|
paragraphIndex: expectation.index,
|
||||||
|
baselinePages: baseline.pageNumbers.join(","),
|
||||||
|
candidatePages: candidate.pageNumbers.join(","),
|
||||||
|
baselineCoverPageCount,
|
||||||
|
candidateCoverPageCount,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (baseline.exclusiveGeometry && candidate.exclusiveGeometry) {
|
if (baseline.exclusiveGeometry && candidate.exclusiveGeometry) {
|
||||||
@@ -373,6 +408,12 @@ export function comparePdfEditableParagraphLayouts(
|
|||||||
const candidateCharacters = candidateLines.map((line) =>
|
const candidateCharacters = candidateLines.map((line) =>
|
||||||
Array.from(line.normalizedText),
|
Array.from(line.normalizedText),
|
||||||
);
|
);
|
||||||
|
const baselineCoverPageCount = baselinePageSemantics.filter(
|
||||||
|
(page) => page.kind === "cover",
|
||||||
|
).length;
|
||||||
|
const candidateCoverPageCount = candidatePageSemantics.filter(
|
||||||
|
(page) => page.kind === "cover",
|
||||||
|
).length;
|
||||||
const baselineCursor: MatchCursor = { lineIndex: 0, characterIndex: 0 };
|
const baselineCursor: MatchCursor = { lineIndex: 0, characterIndex: 0 };
|
||||||
const candidateCursor: MatchCursor = { lineIndex: 0, characterIndex: 0 };
|
const candidateCursor: MatchCursor = { lineIndex: 0, characterIndex: 0 };
|
||||||
|
|
||||||
@@ -393,6 +434,8 @@ export function comparePdfEditableParagraphLayouts(
|
|||||||
expectation,
|
expectation,
|
||||||
baselineObservation,
|
baselineObservation,
|
||||||
candidateObservation,
|
candidateObservation,
|
||||||
|
baselineCoverPageCount,
|
||||||
|
candidateCoverPageCount,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
expectation,
|
expectation,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { comparePdfSnapshotsBasic, DEFAULT_VISUAL_DIFF_THRESHOLDS } from "./compare.js";
|
import { comparePdfSnapshotsBasic, DEFAULT_VISUAL_DIFF_THRESHOLDS } from "./compare.js";
|
||||||
import { comparePageRasters } from "./raster.js";
|
import { comparePageRasters } from "./raster.js";
|
||||||
import { comparePdfPageSemantics } from "./page-semantics.js";
|
import { comparePdfPageSemantics } from "./page-semantics.js";
|
||||||
|
import { comparePdfSemanticBlockVisuals } from "./semantic-block.js";
|
||||||
import type {
|
import type {
|
||||||
CreatePdfVisualDiffOptions,
|
CreatePdfVisualDiffOptions,
|
||||||
PdfDocumentSnapshot,
|
PdfDocumentSnapshot,
|
||||||
@@ -21,6 +22,12 @@ function resolveStatus(
|
|||||||
return issues.length > 0 ? "warning" : "passed";
|
return issues.length > 0 ? "warning" : "passed";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getBlockingVisualDiffIssues(
|
||||||
|
report: Pick<PdfVisualDiffReport, "issues">,
|
||||||
|
): VisualDiffIssue[] {
|
||||||
|
return report.issues.filter((issue) => issue.severity === "failure");
|
||||||
|
}
|
||||||
|
|
||||||
function rasterIssues(
|
function rasterIssues(
|
||||||
pair: Extract<PdfPagePair, { status: "paired" }>,
|
pair: Extract<PdfPagePair, { status: "paired" }>,
|
||||||
metrics: NonNullable<PdfVisualPageComparison["metrics"]>,
|
metrics: NonNullable<PdfVisualPageComparison["metrics"]>,
|
||||||
@@ -91,7 +98,7 @@ async function compareVisualPage(
|
|||||||
baseline: PdfDocumentSnapshot,
|
baseline: PdfDocumentSnapshot,
|
||||||
candidate: PdfDocumentSnapshot,
|
candidate: PdfDocumentSnapshot,
|
||||||
thresholds: VisualDiffThresholds,
|
thresholds: VisualDiffThresholds,
|
||||||
rasterComparisonMode: "strict" | "body-flow" = "strict",
|
rasterComparisonMode: "strict" | "body-flow" | "body-block" = "strict",
|
||||||
): Promise<PdfVisualPageComparison> {
|
): Promise<PdfVisualPageComparison> {
|
||||||
if (pair.status !== "paired") {
|
if (pair.status !== "paired") {
|
||||||
const page =
|
const page =
|
||||||
@@ -101,7 +108,7 @@ async function compareVisualPage(
|
|||||||
return {
|
return {
|
||||||
pair,
|
pair,
|
||||||
rasterComparisonMode,
|
rasterComparisonMode,
|
||||||
status: rasterComparisonMode === "body-flow" ? "warning" : "failed",
|
status: rasterComparisonMode === "strict" ? "failed" : "warning",
|
||||||
...(page?.raster
|
...(page?.raster
|
||||||
? {
|
? {
|
||||||
unpairedPng: page.raster.png,
|
unpairedPng: page.raster.png,
|
||||||
@@ -109,12 +116,18 @@ async function compareVisualPage(
|
|||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
issues:
|
issues:
|
||||||
rasterComparisonMode === "body-flow"
|
rasterComparisonMode !== "strict"
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
code: "BODY_FLOW_RASTER_DIFFERENCE",
|
code:
|
||||||
|
rasterComparisonMode === "body-flow"
|
||||||
|
? "BODY_FLOW_RASTER_DIFFERENCE"
|
||||||
|
: "BODY_PAGE_RASTER_DIFFERENCE",
|
||||||
severity: "warning",
|
severity: "warning",
|
||||||
message: "正文自然分页产生未配对物理页,保留页面快照供审查",
|
message:
|
||||||
|
rasterComparisonMode === "body-flow"
|
||||||
|
? "正文自然分页产生未配对物理页,保留页面快照供审查"
|
||||||
|
: "正文物理页未配对,整页栅格仅作为诊断,最终由语义块门禁判定",
|
||||||
...(pair.status === "baseline-only"
|
...(pair.status === "baseline-only"
|
||||||
? { baselinePageNumber: pair.baselinePageNumber }
|
? { baselinePageNumber: pair.baselinePageNumber }
|
||||||
: { candidatePageNumber: pair.candidatePageNumber }),
|
: { candidatePageNumber: pair.candidatePageNumber }),
|
||||||
@@ -128,14 +141,15 @@ async function compareVisualPage(
|
|||||||
if (!baselinePage?.raster || !candidatePage?.raster) {
|
if (!baselinePage?.raster || !candidatePage?.raster) {
|
||||||
const issue: VisualDiffIssue = {
|
const issue: VisualDiffIssue = {
|
||||||
code: "RASTER_MISSING",
|
code: "RASTER_MISSING",
|
||||||
severity: "failure",
|
severity: rasterComparisonMode === "strict" ? "failure" : "warning",
|
||||||
message: `第 ${pair.baselinePageNumber} 页缺少栅格快照`,
|
message: `第 ${pair.baselinePageNumber} 页缺少栅格快照`,
|
||||||
baselinePageNumber: pair.baselinePageNumber,
|
baselinePageNumber: pair.baselinePageNumber,
|
||||||
candidatePageNumber: pair.candidatePageNumber,
|
candidatePageNumber: pair.candidatePageNumber,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
pair,
|
pair,
|
||||||
status: "failed",
|
rasterComparisonMode,
|
||||||
|
status: rasterComparisonMode === "strict" ? "failed" : "warning",
|
||||||
issues: [issue],
|
issues: [issue],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -161,12 +175,18 @@ async function compareVisualPage(
|
|||||||
);
|
);
|
||||||
const strictIssues = rasterIssues(pair, result.metrics, thresholds);
|
const strictIssues = rasterIssues(pair, result.metrics, thresholds);
|
||||||
const issues =
|
const issues =
|
||||||
rasterComparisonMode === "body-flow" && strictIssues.length > 0
|
rasterComparisonMode !== "strict" && strictIssues.length > 0
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
code: "BODY_FLOW_RASTER_DIFFERENCE" as const,
|
code:
|
||||||
|
rasterComparisonMode === "body-flow"
|
||||||
|
? ("BODY_FLOW_RASTER_DIFFERENCE" as const)
|
||||||
|
: ("BODY_PAGE_RASTER_DIFFERENCE" as const),
|
||||||
severity: "warning" as const,
|
severity: "warning" as const,
|
||||||
message: `第 ${pair.baselinePageNumber} 页栅格差异来自已验证的正文自然分页流动`,
|
message:
|
||||||
|
rasterComparisonMode === "body-flow"
|
||||||
|
? `第 ${pair.baselinePageNumber} 页栅格差异来自已验证的正文自然分页流动`
|
||||||
|
: `第 ${pair.baselinePageNumber} 页为正文页,整页栅格差异仅作诊断,最终由语义块门禁判定`,
|
||||||
baselinePageNumber: pair.baselinePageNumber,
|
baselinePageNumber: pair.baselinePageNumber,
|
||||||
candidatePageNumber: pair.candidatePageNumber,
|
candidatePageNumber: pair.candidatePageNumber,
|
||||||
details: {
|
details: {
|
||||||
@@ -218,6 +238,26 @@ export async function createPdfVisualDiffReport(
|
|||||||
? {}
|
? {}
|
||||||
: { expectedEditableParagraphs: options.expectedEditableParagraphs }),
|
: { expectedEditableParagraphs: options.expectedEditableParagraphs }),
|
||||||
});
|
});
|
||||||
|
if (basic.paragraphLayouts) {
|
||||||
|
const semanticBlockVisuals = await comparePdfSemanticBlockVisuals(
|
||||||
|
baseline,
|
||||||
|
candidate,
|
||||||
|
basic.paragraphLayouts,
|
||||||
|
thresholds,
|
||||||
|
);
|
||||||
|
basic.semanticBlockVisuals = semanticBlockVisuals;
|
||||||
|
basic.issues.push(
|
||||||
|
...semanticBlockVisuals.flatMap((comparison) => comparison.issues),
|
||||||
|
);
|
||||||
|
basic.status = resolveStatus(basic.issues);
|
||||||
|
if (basic.bodyFlow) {
|
||||||
|
basic.bodyFlow.legal =
|
||||||
|
basic.bodyFlow.legal &&
|
||||||
|
semanticBlockVisuals.every(
|
||||||
|
(comparison) => comparison.status !== "failed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
const pages: PdfVisualPageComparison[] = [];
|
const pages: PdfVisualPageComparison[] = [];
|
||||||
for (const pair of basic.pagePairs) {
|
for (const pair of basic.pagePairs) {
|
||||||
const baselineSemanticExpectation =
|
const baselineSemanticExpectation =
|
||||||
@@ -244,12 +284,21 @@ export async function createPdfVisualDiffReport(
|
|||||||
pair.candidatePageNumber,
|
pair.candidatePageNumber,
|
||||||
))),
|
))),
|
||||||
);
|
);
|
||||||
|
const isSemanticBodyPage =
|
||||||
|
baselineSemanticExpectation?.kind !== "cover" &&
|
||||||
|
candidateSemanticExpectation?.kind !== "cover" &&
|
||||||
|
Boolean(baselineSemanticExpectation || candidateSemanticExpectation) &&
|
||||||
|
(basic.semanticBlockVisuals?.length ?? 0) > 0;
|
||||||
const page = await compareVisualPage(
|
const page = await compareVisualPage(
|
||||||
pair,
|
pair,
|
||||||
baseline,
|
baseline,
|
||||||
candidate,
|
candidate,
|
||||||
thresholds,
|
thresholds,
|
||||||
flowAffected ? "body-flow" : "strict",
|
flowAffected
|
||||||
|
? "body-flow"
|
||||||
|
: isSemanticBodyPage
|
||||||
|
? "body-block"
|
||||||
|
: "strict",
|
||||||
);
|
);
|
||||||
const primarySemanticExpectation =
|
const primarySemanticExpectation =
|
||||||
baselineSemanticExpectation ?? candidateSemanticExpectation;
|
baselineSemanticExpectation ?? candidateSemanticExpectation;
|
||||||
@@ -366,6 +415,8 @@ function pageTitle(page: PdfVisualPageComparison): string {
|
|||||||
const mode =
|
const mode =
|
||||||
page.rasterComparisonMode === "body-flow"
|
page.rasterComparisonMode === "body-flow"
|
||||||
? "(正文流动比较)"
|
? "(正文流动比较)"
|
||||||
|
: page.rasterComparisonMode === "body-block"
|
||||||
|
? "(正文整页诊断)"
|
||||||
: "(严格比较)";
|
: "(严格比较)";
|
||||||
if (page.pair.status === "paired") {
|
if (page.pair.status === "paired") {
|
||||||
return `基线第 ${page.pair.baselinePageNumber} 页 ↔ 候选第 ${page.pair.candidatePageNumber} 页${mode}`;
|
return `基线第 ${page.pair.baselinePageNumber} 页 ↔ 候选第 ${page.pair.candidatePageNumber} 页${mode}`;
|
||||||
@@ -376,6 +427,27 @@ function pageTitle(page: PdfVisualPageComparison): string {
|
|||||||
return `候选第 ${page.pair.candidatePageNumber} 页为溢出页${mode}`;
|
return `候选第 ${page.pair.candidatePageNumber} 页为溢出页${mode}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderSemanticBlockVisuals(report: PdfVisualDiffReport): string {
|
||||||
|
const comparisons = report.basic.semanticBlockVisuals;
|
||||||
|
if (!comparisons) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const failures = comparisons.filter(
|
||||||
|
(comparison) => comparison.status === "failed",
|
||||||
|
);
|
||||||
|
const rows = (failures.length > 0 ? failures : comparisons.slice(0, 8))
|
||||||
|
.map((comparison) => {
|
||||||
|
const expectation = comparison.expectation;
|
||||||
|
return `<tr><td>${expectation.index + 1}</td><td>${escapeHtml(expectation.section)}</td><td>${escapeHtml(expectation.role)}</td><td>${escapeHtml(comparison.baselineFontFamilies.join(", ") || "未识别")}</td><td>${escapeHtml(comparison.candidateFontFamilies.join(", ") || "未识别")}</td><td>${comparison.lines.length}</td><td>${escapeHtml(comparison.issues.map((issue) => issue.code).join(", ") || "通过")}</td></tr>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
return `<div class="semantic-block-visuals">
|
||||||
|
<h2>正文语义块局部视觉 <span class="status ${failures.length > 0 ? "failed" : "passed"}">${failures.length > 0 ? "failed" : "passed"}</span></h2>
|
||||||
|
<p>正文语义块 ${comparisons.length} 个,失败 ${failures.length} 个;块按内容顺序匹配,局部栅格参与硬门禁,物理页号不参与。PDF.js 字体分类仅作诊断,真实字体解析由独立字体门禁判定。</p>
|
||||||
|
<table><thead><tr><th>块</th><th>分节</th><th>角色</th><th>基线字体分类</th><th>候选字体分类</th><th>局部行</th><th>结果</th></tr></thead><tbody>${rows}</tbody></table>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderParagraphLayouts(report: PdfVisualDiffReport): string {
|
function renderParagraphLayouts(report: PdfVisualDiffReport): string {
|
||||||
const paragraphs = report.basic.paragraphLayouts;
|
const paragraphs = report.basic.paragraphLayouts;
|
||||||
if (!paragraphs) {
|
if (!paragraphs) {
|
||||||
@@ -451,6 +523,7 @@ export function renderPdfVisualDiffHtml(
|
|||||||
<p><strong>生成时间:</strong>${escapeHtml(report.generatedAt)} <strong>正文相似度:</strong>${percentage(report.basic.contentSimilarity)}</p>
|
<p><strong>生成时间:</strong>${escapeHtml(report.generatedAt)} <strong>正文相似度:</strong>${percentage(report.basic.contentSimilarity)}</p>
|
||||||
${editableMetrics}
|
${editableMetrics}
|
||||||
${renderParagraphLayouts(report)}
|
${renderParagraphLayouts(report)}
|
||||||
|
${renderSemanticBlockVisuals(report)}
|
||||||
<table><thead><tr><th>级别</th><th>代码</th><th>说明</th></tr></thead><tbody>${issueRows}</tbody></table>
|
<table><thead><tr><th>级别</th><th>代码</th><th>说明</th></tr></thead><tbody>${issueRows}</tbody></table>
|
||||||
</section>
|
</section>
|
||||||
${pages}
|
${pages}
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
import { createCanvas, loadImage } from "@napi-rs/canvas";
|
||||||
|
|
||||||
|
import { comparePageRasters } from "./raster.js";
|
||||||
|
import type {
|
||||||
|
PdfDocumentSnapshot,
|
||||||
|
PdfPageRaster,
|
||||||
|
PdfParagraphLayoutComparison,
|
||||||
|
PdfPointBounds,
|
||||||
|
PdfSemanticBlockVisualComparison,
|
||||||
|
PdfSemanticBlockVisualLineComparison,
|
||||||
|
VisualDiffIssue,
|
||||||
|
VisualDiffThresholds,
|
||||||
|
} from "./types.js";
|
||||||
|
|
||||||
|
const BLOCK_RASTER_PADDING_PT = 4;
|
||||||
|
|
||||||
|
function sha256(bytes: Uint8Array): string {
|
||||||
|
return createHash("sha256").update(bytes).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function status(issues: readonly VisualDiffIssue[]) {
|
||||||
|
return issues.some((issue) => issue.severity === "failure")
|
||||||
|
? ("failed" as const)
|
||||||
|
: issues.length > 0
|
||||||
|
? ("warning" as const)
|
||||||
|
: ("passed" as const);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFontFamily(value: string): string {
|
||||||
|
return value
|
||||||
|
.normalize("NFKC")
|
||||||
|
.replace(/^[A-Z]{6}\+/u, "")
|
||||||
|
.replace(/["']/gu, "")
|
||||||
|
.replace(/\s+/gu, " ")
|
||||||
|
.trim()
|
||||||
|
.toLocaleLowerCase("en-US");
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectFonts(
|
||||||
|
lines: PdfParagraphLayoutComparison["baseline"]["visualLines"],
|
||||||
|
): string[] {
|
||||||
|
return [
|
||||||
|
...new Set(
|
||||||
|
lines.flatMap((line) => line.fontFamilies.map(normalizeFontFamily)),
|
||||||
|
),
|
||||||
|
].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cropRaster(
|
||||||
|
raster: PdfPageRaster,
|
||||||
|
bounds: PdfPointBounds,
|
||||||
|
): Promise<PdfPageRaster> {
|
||||||
|
const pixelsPerPoint = raster.dpi / 72;
|
||||||
|
const left = Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor((bounds.x - BLOCK_RASTER_PADDING_PT) * pixelsPerPoint),
|
||||||
|
);
|
||||||
|
const top = Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor((bounds.y - BLOCK_RASTER_PADDING_PT) * pixelsPerPoint),
|
||||||
|
);
|
||||||
|
const right = Math.min(
|
||||||
|
raster.widthPx,
|
||||||
|
Math.ceil(
|
||||||
|
(bounds.x + bounds.width + BLOCK_RASTER_PADDING_PT) * pixelsPerPoint,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const bottom = Math.min(
|
||||||
|
raster.heightPx,
|
||||||
|
Math.ceil(
|
||||||
|
(bounds.y + bounds.height + BLOCK_RASTER_PADDING_PT) * pixelsPerPoint,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const widthPx = Math.max(1, right - left);
|
||||||
|
const heightPx = Math.max(1, bottom - top);
|
||||||
|
const source = await loadImage(Buffer.from(raster.png));
|
||||||
|
const canvas = createCanvas(widthPx, heightPx);
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
context.fillStyle = "#ffffff";
|
||||||
|
context.fillRect(0, 0, widthPx, heightPx);
|
||||||
|
context.drawImage(
|
||||||
|
source,
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
widthPx,
|
||||||
|
heightPx,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
widthPx,
|
||||||
|
heightPx,
|
||||||
|
);
|
||||||
|
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||||
|
return {
|
||||||
|
widthPx,
|
||||||
|
heightPx,
|
||||||
|
dpi: raster.dpi,
|
||||||
|
sha256: sha256(png),
|
||||||
|
png,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function padRaster(
|
||||||
|
raster: PdfPageRaster,
|
||||||
|
widthPx: number,
|
||||||
|
heightPx: number,
|
||||||
|
): Promise<PdfPageRaster> {
|
||||||
|
if (raster.widthPx === widthPx && raster.heightPx === heightPx) {
|
||||||
|
return raster;
|
||||||
|
}
|
||||||
|
const source = await loadImage(Buffer.from(raster.png));
|
||||||
|
const canvas = createCanvas(widthPx, heightPx);
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
context.fillStyle = "#ffffff";
|
||||||
|
context.fillRect(0, 0, widthPx, heightPx);
|
||||||
|
context.drawImage(source, 0, 0);
|
||||||
|
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||||
|
return {
|
||||||
|
widthPx,
|
||||||
|
heightPx,
|
||||||
|
dpi: raster.dpi,
|
||||||
|
sha256: sha256(png),
|
||||||
|
png,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function localRasterIssues(
|
||||||
|
paragraphIndex: number,
|
||||||
|
lineIndex: number,
|
||||||
|
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
|
||||||
|
thresholds: VisualDiffThresholds,
|
||||||
|
): VisualDiffIssue[] {
|
||||||
|
const failed =
|
||||||
|
metrics.meanAbsoluteError > thresholds.maxMeanAbsoluteError ||
|
||||||
|
metrics.changedPixelRatio > thresholds.maxChangedPixelRatio ||
|
||||||
|
metrics.inkIou < thresholds.minInkIou ||
|
||||||
|
metrics.edgeIou < thresholds.minEdgeIou;
|
||||||
|
if (!failed) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
code: "SEMANTIC_BLOCK_RASTER_MISMATCH",
|
||||||
|
severity: "failure",
|
||||||
|
message: `第 ${paragraphIndex + 1} 个语义块的第 ${lineIndex + 1} 行局部视觉差异超限`,
|
||||||
|
details: {
|
||||||
|
paragraphIndex,
|
||||||
|
lineIndex,
|
||||||
|
meanAbsoluteError: metrics.meanAbsoluteError,
|
||||||
|
changedPixelRatio: metrics.changedPixelRatio,
|
||||||
|
inkIou: metrics.inkIou,
|
||||||
|
edgeIou: metrics.edgeIou,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function comparePdfSemanticBlockVisuals(
|
||||||
|
baseline: PdfDocumentSnapshot,
|
||||||
|
candidate: PdfDocumentSnapshot,
|
||||||
|
paragraphs: readonly PdfParagraphLayoutComparison[],
|
||||||
|
thresholds: VisualDiffThresholds,
|
||||||
|
): Promise<PdfSemanticBlockVisualComparison[]> {
|
||||||
|
const comparisons: PdfSemanticBlockVisualComparison[] = [];
|
||||||
|
for (const paragraph of paragraphs) {
|
||||||
|
if (paragraph.expectation.section !== "body") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const issues: VisualDiffIssue[] = [];
|
||||||
|
const baselineFonts = collectFonts(paragraph.baseline.visualLines);
|
||||||
|
const candidateFonts = collectFonts(paragraph.candidate.visualLines);
|
||||||
|
|
||||||
|
const lines: PdfSemanticBlockVisualLineComparison[] = [];
|
||||||
|
const comparableLineCount = Math.min(
|
||||||
|
paragraph.baseline.visualLines.length,
|
||||||
|
paragraph.candidate.visualLines.length,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!paragraph.baseline.matched ||
|
||||||
|
!paragraph.candidate.matched ||
|
||||||
|
comparableLineCount === 0
|
||||||
|
) {
|
||||||
|
issues.push({
|
||||||
|
code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE",
|
||||||
|
severity: "failure",
|
||||||
|
message: `第 ${paragraph.expectation.index + 1} 个语义块缺少可比较的局部视觉观测`,
|
||||||
|
details: { paragraphIndex: paragraph.expectation.index },
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
paragraph.baseline.visualLines.length ===
|
||||||
|
paragraph.candidate.visualLines.length
|
||||||
|
) {
|
||||||
|
for (let lineIndex = 0; lineIndex < comparableLineCount; lineIndex += 1) {
|
||||||
|
const baselineLine = paragraph.baseline.visualLines[lineIndex];
|
||||||
|
const candidateLine = paragraph.candidate.visualLines[lineIndex];
|
||||||
|
const baselinePage = baselineLine
|
||||||
|
? baseline.pages[baselineLine.pageNumber - 1]
|
||||||
|
: undefined;
|
||||||
|
const candidatePage = candidateLine
|
||||||
|
? candidate.pages[candidateLine.pageNumber - 1]
|
||||||
|
: undefined;
|
||||||
|
if (
|
||||||
|
!baselineLine ||
|
||||||
|
!candidateLine ||
|
||||||
|
!baselinePage?.raster ||
|
||||||
|
!candidatePage?.raster
|
||||||
|
) {
|
||||||
|
const unavailable: VisualDiffIssue = {
|
||||||
|
code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE",
|
||||||
|
severity: "failure",
|
||||||
|
message: `第 ${paragraph.expectation.index + 1} 个语义块的第 ${lineIndex + 1} 行缺少栅格`,
|
||||||
|
details: {
|
||||||
|
paragraphIndex: paragraph.expectation.index,
|
||||||
|
lineIndex,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
issues.push(unavailable);
|
||||||
|
lines.push({
|
||||||
|
lineIndex,
|
||||||
|
baselinePageNumber: baselineLine?.pageNumber ?? -1,
|
||||||
|
candidatePageNumber: candidateLine?.pageNumber ?? -1,
|
||||||
|
issues: [unavailable],
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [baselineCrop, candidateCrop] = await Promise.all([
|
||||||
|
cropRaster(baselinePage.raster, baselineLine.bounds),
|
||||||
|
cropRaster(candidatePage.raster, candidateLine.bounds),
|
||||||
|
]);
|
||||||
|
const widthPx = Math.max(baselineCrop.widthPx, candidateCrop.widthPx);
|
||||||
|
const heightPx = Math.max(
|
||||||
|
baselineCrop.heightPx,
|
||||||
|
candidateCrop.heightPx,
|
||||||
|
);
|
||||||
|
const [baselinePadded, candidatePadded] = await Promise.all([
|
||||||
|
padRaster(baselineCrop, widthPx, heightPx),
|
||||||
|
padRaster(candidateCrop, widthPx, heightPx),
|
||||||
|
]);
|
||||||
|
const result = await comparePageRasters(
|
||||||
|
baselinePadded,
|
||||||
|
candidatePadded,
|
||||||
|
thresholds.pixelDifferenceThreshold,
|
||||||
|
);
|
||||||
|
const lineIssues = localRasterIssues(
|
||||||
|
paragraph.expectation.index,
|
||||||
|
lineIndex,
|
||||||
|
result.metrics,
|
||||||
|
thresholds,
|
||||||
|
);
|
||||||
|
issues.push(...lineIssues);
|
||||||
|
lines.push({
|
||||||
|
lineIndex,
|
||||||
|
baselinePageNumber: baselineLine.pageNumber,
|
||||||
|
candidatePageNumber: candidateLine.pageNumber,
|
||||||
|
metrics: result.metrics,
|
||||||
|
...(lineIssues.length > 0 ? { artifacts: result.artifacts } : {}),
|
||||||
|
issues: lineIssues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
comparisons.push({
|
||||||
|
expectation: paragraph.expectation,
|
||||||
|
status: status(issues),
|
||||||
|
baselineFontFamilies: baselineFonts,
|
||||||
|
candidateFontFamilies: candidateFonts,
|
||||||
|
lines,
|
||||||
|
issues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return comparisons;
|
||||||
|
}
|
||||||
@@ -21,6 +21,10 @@ interface PdfJsTextItem {
|
|||||||
hasEOL: boolean;
|
hasEOL: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PdfJsTextStyle {
|
||||||
|
fontFamily?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const DEFAULT_PDF_SNAPSHOT_LIMITS: PdfSnapshotLimits = {
|
export const DEFAULT_PDF_SNAPSHOT_LIMITS: PdfSnapshotLimits = {
|
||||||
maxPdfBytes: 100 * 1024 * 1024,
|
maxPdfBytes: 100 * 1024 * 1024,
|
||||||
maxPages: 500,
|
maxPages: 500,
|
||||||
@@ -55,6 +59,7 @@ function isTextItem(item: unknown): item is PdfJsTextItem {
|
|||||||
function createTextItemSnapshot(
|
function createTextItemSnapshot(
|
||||||
item: PdfJsTextItem,
|
item: PdfJsTextItem,
|
||||||
pageHeightPt: number,
|
pageHeightPt: number,
|
||||||
|
styles: Readonly<Record<string, PdfJsTextStyle>>,
|
||||||
): PdfTextItemSnapshot | undefined {
|
): PdfTextItemSnapshot | undefined {
|
||||||
const normalizedText = normalizePdfText(item.str);
|
const normalizedText = normalizePdfText(item.str);
|
||||||
if (!normalizedText) {
|
if (!normalizedText) {
|
||||||
@@ -72,6 +77,7 @@ function createTextItemSnapshot(
|
|||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const baselineY = pageHeightPt - baselineFromBottom;
|
const baselineY = pageHeightPt - baselineFromBottom;
|
||||||
|
const fontFamily = styles[item.fontName]?.fontFamily;
|
||||||
return {
|
return {
|
||||||
text: item.str,
|
text: item.str,
|
||||||
normalizedText,
|
normalizedText,
|
||||||
@@ -83,6 +89,7 @@ function createTextItemSnapshot(
|
|||||||
},
|
},
|
||||||
baselineY,
|
baselineY,
|
||||||
fontName: item.fontName,
|
fontName: item.fontName,
|
||||||
|
...(fontFamily ? { fontFamily } : {}),
|
||||||
hasEol: item.hasEOL,
|
hasEol: item.hasEOL,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -138,11 +145,18 @@ export async function createPdfDocumentSnapshot(
|
|||||||
disableNormalization: false,
|
disableNormalization: false,
|
||||||
includeMarkedContent: false,
|
includeMarkedContent: false,
|
||||||
});
|
});
|
||||||
|
const styles = textContent.styles as Readonly<
|
||||||
|
Record<string, PdfJsTextStyle>
|
||||||
|
>;
|
||||||
const items = textContent.items.flatMap((item) => {
|
const items = textContent.items.flatMap((item) => {
|
||||||
if (!isTextItem(item)) {
|
if (!isTextItem(item)) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const snapshot = createTextItemSnapshot(item, pointViewport.height);
|
const snapshot = createTextItemSnapshot(
|
||||||
|
item,
|
||||||
|
pointViewport.height,
|
||||||
|
styles,
|
||||||
|
);
|
||||||
return snapshot ? [snapshot] : [];
|
return snapshot ? [snapshot] : [];
|
||||||
});
|
});
|
||||||
const lines = aggregatePdfTextLines(items, pointViewport.height);
|
const lines = aggregatePdfTextLines(items, pointViewport.height);
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface PdfTextItemSnapshot {
|
|||||||
bounds: PdfPointBounds;
|
bounds: PdfPointBounds;
|
||||||
baselineY: number;
|
baselineY: number;
|
||||||
fontName?: string;
|
fontName?: string;
|
||||||
|
fontFamily?: string;
|
||||||
hasEol: boolean;
|
hasEol: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +69,13 @@ export interface PdfParagraphLayoutObservation {
|
|||||||
averageLineHeightPt?: number;
|
averageLineHeightPt?: number;
|
||||||
averageBaselineGapPt?: number;
|
averageBaselineGapPt?: number;
|
||||||
exclusiveGeometry: boolean;
|
exclusiveGeometry: boolean;
|
||||||
|
visualLines: PdfParagraphVisualLineObservation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PdfParagraphVisualLineObservation {
|
||||||
|
pageNumber: number;
|
||||||
|
bounds: PdfPointBounds;
|
||||||
|
fontFamilies: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PdfParagraphLayoutComparison {
|
export interface PdfParagraphLayoutComparison {
|
||||||
@@ -78,6 +86,24 @@ export interface PdfParagraphLayoutComparison {
|
|||||||
issues: VisualDiffIssue[];
|
issues: VisualDiffIssue[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PdfSemanticBlockVisualLineComparison {
|
||||||
|
lineIndex: number;
|
||||||
|
baselinePageNumber: number;
|
||||||
|
candidatePageNumber: number;
|
||||||
|
metrics?: PdfRasterDiffMetrics;
|
||||||
|
artifacts?: PdfRasterDiffArtifacts;
|
||||||
|
issues: VisualDiffIssue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PdfSemanticBlockVisualComparison {
|
||||||
|
expectation: EditableParagraphExpectation;
|
||||||
|
status: "passed" | "warning" | "failed";
|
||||||
|
baselineFontFamilies: string[];
|
||||||
|
candidateFontFamilies: string[];
|
||||||
|
lines: PdfSemanticBlockVisualLineComparison[];
|
||||||
|
issues: VisualDiffIssue[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface PdfBodyFlowComparison {
|
export interface PdfBodyFlowComparison {
|
||||||
detected: boolean;
|
detected: boolean;
|
||||||
legal: boolean;
|
legal: boolean;
|
||||||
@@ -171,9 +197,12 @@ export type VisualDiffIssueCode =
|
|||||||
| "BODY_LINE_COUNT_NEAR_BOUNDARY"
|
| "BODY_LINE_COUNT_NEAR_BOUNDARY"
|
||||||
| "PARAGRAPH_LINE_BREAK_MISMATCH"
|
| "PARAGRAPH_LINE_BREAK_MISMATCH"
|
||||||
| "PARAGRAPH_LINE_GEOMETRY_MISMATCH"
|
| "PARAGRAPH_LINE_GEOMETRY_MISMATCH"
|
||||||
|
| "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE"
|
||||||
|
| "SEMANTIC_BLOCK_RASTER_MISMATCH"
|
||||||
| "COVER_LAYOUT_MISMATCH"
|
| "COVER_LAYOUT_MISMATCH"
|
||||||
| "BODY_FLOW_PAGE_COUNT_DIFFERENCE"
|
| "BODY_FLOW_PAGE_COUNT_DIFFERENCE"
|
||||||
| "BODY_FLOW_RASTER_DIFFERENCE";
|
| "BODY_FLOW_RASTER_DIFFERENCE"
|
||||||
|
| "BODY_PAGE_RASTER_DIFFERENCE";
|
||||||
|
|
||||||
export interface VisualDiffIssue {
|
export interface VisualDiffIssue {
|
||||||
code: VisualDiffIssueCode;
|
code: VisualDiffIssueCode;
|
||||||
@@ -210,6 +239,7 @@ export interface PdfBasicComparison {
|
|||||||
candidateEditableSimilarity?: number;
|
candidateEditableSimilarity?: number;
|
||||||
candidateEditableExact?: boolean;
|
candidateEditableExact?: boolean;
|
||||||
paragraphLayouts?: PdfParagraphLayoutComparison[];
|
paragraphLayouts?: PdfParagraphLayoutComparison[];
|
||||||
|
semanticBlockVisuals?: PdfSemanticBlockVisualComparison[];
|
||||||
bodyFlow?: PdfBodyFlowComparison;
|
bodyFlow?: PdfBodyFlowComparison;
|
||||||
pagePairs: PdfPagePair[];
|
pagePairs: PdfPagePair[];
|
||||||
issues: VisualDiffIssue[];
|
issues: VisualDiffIssue[];
|
||||||
@@ -255,7 +285,7 @@ export interface PdfRasterDiffResult {
|
|||||||
|
|
||||||
export interface PdfVisualPageComparison {
|
export interface PdfVisualPageComparison {
|
||||||
pair: PdfPagePair;
|
pair: PdfPagePair;
|
||||||
rasterComparisonMode?: "strict" | "body-flow";
|
rasterComparisonMode?: "strict" | "body-flow" | "body-block";
|
||||||
status: "passed" | "warning" | "failed";
|
status: "passed" | "warning" | "failed";
|
||||||
metrics?: PdfRasterDiffMetrics;
|
metrics?: PdfRasterDiffMetrics;
|
||||||
artifacts?: PdfRasterDiffArtifacts;
|
artifacts?: PdfRasterDiffArtifacts;
|
||||||
|
|||||||
@@ -166,6 +166,35 @@ describe("PDF 内容感知段落版式门禁", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 文本提取器失真的单行宽度但保留诊断值", () => {
|
it("忽略 PDF 文本提取器失真的单行宽度但保留诊断值", () => {
|
||||||
const baseline = snapshot("baseline", [
|
const baseline = snapshot("baseline", [
|
||||||
[line("数据治理平台建设项目", 300, 224)],
|
[line("数据治理平台建设项目", 300, 224)],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
createPdfVisualDiffReport,
|
createPdfVisualDiffReport,
|
||||||
|
getBlockingVisualDiffIssues,
|
||||||
renderPdfVisualDiffHtml,
|
renderPdfVisualDiffHtml,
|
||||||
serializePdfVisualDiffJson,
|
serializePdfVisualDiffJson,
|
||||||
type PdfDocumentSnapshot,
|
type PdfDocumentSnapshot,
|
||||||
@@ -30,6 +31,40 @@ function raster(color: string): PdfPageRaster {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function flowRaster(color: string): PdfPageRaster {
|
||||||
|
const canvas = createCanvas(40, 50);
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
context.fillStyle = "#ffffff";
|
||||||
|
context.fillRect(0, 0, 40, 50);
|
||||||
|
context.fillStyle = color;
|
||||||
|
context.fillRect(34, 5, 6, 40);
|
||||||
|
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||||
|
return {
|
||||||
|
widthPx: 40,
|
||||||
|
heightPx: 50,
|
||||||
|
dpi: 144,
|
||||||
|
sha256: createHash("sha256").update(png).digest("hex"),
|
||||||
|
png,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function blockRaster(color: string): PdfPageRaster {
|
||||||
|
const canvas = createCanvas(40, 50);
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
context.fillStyle = "#ffffff";
|
||||||
|
context.fillRect(0, 0, 40, 50);
|
||||||
|
context.fillStyle = color;
|
||||||
|
context.fillRect(8, 12, 20, 10);
|
||||||
|
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||||
|
return {
|
||||||
|
widthPx: 40,
|
||||||
|
heightPx: 50,
|
||||||
|
dpi: 144,
|
||||||
|
sha256: createHash("sha256").update(png).digest("hex"),
|
||||||
|
png,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function snapshot(label: string, pageRaster: PdfPageRaster): PdfDocumentSnapshot {
|
function snapshot(label: string, pageRaster: PdfPageRaster): PdfDocumentSnapshot {
|
||||||
return {
|
return {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
@@ -82,7 +117,7 @@ function flowSnapshot(
|
|||||||
items: [],
|
items: [],
|
||||||
lines: [...lines],
|
lines: [...lines],
|
||||||
contentText: lines.map((line) => line.normalizedText).join(""),
|
contentText: lines.map((line) => line.normalizedText).join(""),
|
||||||
raster: raster(color),
|
raster: flowRaster(color),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -387,7 +422,7 @@ describe("PDF 视觉差异报告", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("整段跨页同时发生换行变化时仍保持严格失败", async () => {
|
it("整段跨页同时发生换行变化时由语义块门禁失败", async () => {
|
||||||
const baseline = flowSnapshot(
|
const baseline = flowSnapshot(
|
||||||
"baseline",
|
"baseline",
|
||||||
[[contentLine("甲", 8)], [contentLine("乙丙", 8)]],
|
[[contentLine("甲", 8)], [contentLine("乙丙", 8)]],
|
||||||
@@ -411,7 +446,7 @@ describe("PDF 视觉差异报告", () => {
|
|||||||
expect(report.status).toBe("failed");
|
expect(report.status).toBe("failed");
|
||||||
expect(report.basic.bodyFlow).toMatchObject({ detected: true, legal: false });
|
expect(report.basic.bodyFlow).toMatchObject({ detected: true, legal: false });
|
||||||
expect(report.pages.every(
|
expect(report.pages.every(
|
||||||
(page) => page.rasterComparisonMode === "strict",
|
(page) => page.rasterComparisonMode === "body-block",
|
||||||
)).toBe(true);
|
)).toBe(true);
|
||||||
expect(report.issues.map((issue) => issue.code)).toContain(
|
expect(report.issues.map((issue) => issue.code)).toContain(
|
||||||
"TEXT_BLOCK_LINE_COUNT_MISMATCH",
|
"TEXT_BLOCK_LINE_COUNT_MISMATCH",
|
||||||
@@ -498,4 +533,78 @@ describe("PDF 视觉差异报告", () => {
|
|||||||
"PAGE_NUMBER_VISIBILITY_MISMATCH",
|
"PAGE_NUMBER_VISIBILITY_MISMATCH",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("正文整页差异只作诊断,但语义块局部视觉变化必须失败", async () => {
|
||||||
|
const baseline = flowSnapshot(
|
||||||
|
"baseline",
|
||||||
|
[[contentLine("甲", 8)]],
|
||||||
|
"#111111",
|
||||||
|
);
|
||||||
|
const candidate = flowSnapshot(
|
||||||
|
"candidate",
|
||||||
|
[[contentLine("甲", 8)]],
|
||||||
|
"#111111",
|
||||||
|
);
|
||||||
|
baseline.pages[0]!.raster = blockRaster("#111111");
|
||||||
|
candidate.pages[0]!.raster = blockRaster("#cc0000");
|
||||||
|
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||||
|
expectedEditableText: "甲",
|
||||||
|
expectedEditableParagraphs: [flowParagraphs[0]!],
|
||||||
|
pageSemantics: bodySemantics(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(report.status).toBe("failed");
|
||||||
|
expect(report.pages[0]?.rasterComparisonMode).toBe("body-block");
|
||||||
|
expect(report.issues.map((issue) => issue.code)).toContain(
|
||||||
|
"BODY_PAGE_RASTER_DIFFERENCE",
|
||||||
|
);
|
||||||
|
expect(report.issues.map((issue) => issue.code)).toContain(
|
||||||
|
"SEMANTIC_BLOCK_RASTER_MISMATCH",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("缺少语义块证据时不得把正文整页栅格自动降级", async () => {
|
||||||
|
const report = await createPdfVisualDiffReport(
|
||||||
|
snapshot("baseline", raster("#111111")),
|
||||||
|
snapshot("candidate", raster("#cc0000")),
|
||||||
|
{ pageSemantics: bodySemantics(1) },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(report.status).toBe("failed");
|
||||||
|
expect(report.pages[0]?.rasterComparisonMode).toBe("strict");
|
||||||
|
expect(report.issues.map((issue) => issue.code)).toContain(
|
||||||
|
"CHANGED_PIXEL_RATIO_EXCEEDED",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("所有 failure 级视觉问题都必须进入发布阻断集合", () => {
|
||||||
|
const failure = {
|
||||||
|
code: "INK_IOU_BELOW_THRESHOLD" as const,
|
||||||
|
severity: "failure" as const,
|
||||||
|
message: "墨迹 IoU 未达到门限",
|
||||||
|
};
|
||||||
|
const warning = {
|
||||||
|
code: "BODY_FLOW_RASTER_DIFFERENCE" as const,
|
||||||
|
severity: "warning" as const,
|
||||||
|
message: "正文自然分页差异",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(getBlockingVisualDiffIssues({ issues: [failure, warning] })).toEqual([
|
||||||
|
failure,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("正文自然分页 warning 不阻断发布", () => {
|
||||||
|
expect(
|
||||||
|
getBlockingVisualDiffIssues({
|
||||||
|
issues: [
|
||||||
|
{
|
||||||
|
code: "BODY_FLOW_RASTER_DIFFERENCE",
|
||||||
|
severity: "warning",
|
||||||
|
message: "正文自然分页差异",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
createPdfVisualDiffReport,
|
createPdfVisualDiffReport,
|
||||||
createWordPdfAdapter,
|
createWordPdfAdapter,
|
||||||
createWpsPdfAdapter,
|
createWpsPdfAdapter,
|
||||||
|
getBlockingVisualDiffIssues,
|
||||||
renderPdfVisualDiffHtml,
|
renderPdfVisualDiffHtml,
|
||||||
serializePdfVisualDiffJson
|
serializePdfVisualDiffJson
|
||||||
} from "../packages/document-visual-diff/dist/index.js";
|
} from "../packages/document-visual-diff/dist/index.js";
|
||||||
@@ -41,24 +42,6 @@ const pageSizes = {
|
|||||||
A4: { portrait: [595.276, 841.89], landscape: [841.89, 595.276] },
|
A4: { portrait: [595.276, 841.89], landscape: [841.89, 595.276] },
|
||||||
Letter: { portrait: [612, 792], landscape: [792, 612] }
|
Letter: { portrait: [612, 792], landscape: [792, 612] }
|
||||||
};
|
};
|
||||||
const strictIssueCodes = new Set([
|
|
||||||
"PAGE_SIZE_MISMATCH",
|
|
||||||
"PAGE_ORIENTATION_MISMATCH",
|
|
||||||
"CONTENT_MISMATCH",
|
|
||||||
"PAGE_NUMBER_VISIBILITY_MISMATCH",
|
|
||||||
"PAGE_NUMBER_TEXT_MISMATCH",
|
|
||||||
"PAGE_NUMBER_ALIGNMENT_MISMATCH",
|
|
||||||
"PAGE_NUMBER_VERTICAL_POSITION_MISMATCH",
|
|
||||||
"HEADER_VISIBILITY_MISMATCH",
|
|
||||||
"HEADER_TEXT_MISSING",
|
|
||||||
"HEADER_VERTICAL_POSITION_MISMATCH",
|
|
||||||
"EDITABLE_PARAGRAPH_MAPPING_MISMATCH",
|
|
||||||
"TEXT_BLOCK_LINE_COUNT_MISMATCH",
|
|
||||||
"PARAGRAPH_LINE_BREAK_MISMATCH",
|
|
||||||
"PARAGRAPH_LINE_GEOMETRY_MISMATCH",
|
|
||||||
"COVER_LAYOUT_MISMATCH"
|
|
||||||
]);
|
|
||||||
|
|
||||||
const cases = [
|
const cases = [
|
||||||
{
|
{
|
||||||
id: "official-theme-a4-portrait",
|
id: "official-theme-a4-portrait",
|
||||||
@@ -124,6 +107,20 @@ const cases = [
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "tender-blind-cover-a4-portrait",
|
||||||
|
themeId: "tender-blind",
|
||||||
|
coverPageCount: 1,
|
||||||
|
paper: { format: "A4", orientation: "portrait" },
|
||||||
|
config: {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tender-classic-cover-a4-portrait",
|
||||||
|
themeId: "tender-classic",
|
||||||
|
coverPageCount: 1,
|
||||||
|
paper: { format: "A4", orientation: "portrait" },
|
||||||
|
config: {}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "formal-custom-a4-landscape",
|
id: "formal-custom-a4-landscape",
|
||||||
themeId: "formal-regulation",
|
themeId: "formal-regulation",
|
||||||
@@ -379,8 +376,7 @@ function buildPageSemantics(pageCount, coverPageCount, config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function reportGateFailures(report, label) {
|
function reportGateFailures(report, label) {
|
||||||
const failures = report.issues
|
const failures = getBlockingVisualDiffIssues(report)
|
||||||
.filter((issue) => strictIssueCodes.has(issue.code))
|
|
||||||
.map((issue) => `${label}: ${issue.code} - ${issue.message}`);
|
.map((issue) => `${label}: ${issue.code} - ${issue.message}`);
|
||||||
if (report.basic.baselineEditableCoverage !== 1) {
|
if (report.basic.baselineEditableCoverage !== 1) {
|
||||||
failures.push(
|
failures.push(
|
||||||
@@ -400,18 +396,21 @@ function reportSummary(report) {
|
|||||||
const paragraphIssues = report.basic.paragraphLayouts?.flatMap(
|
const paragraphIssues = report.basic.paragraphLayouts?.flatMap(
|
||||||
(paragraph) => paragraph.issues
|
(paragraph) => paragraph.issues
|
||||||
) ?? [];
|
) ?? [];
|
||||||
|
const semanticBlocks = report.basic.semanticBlockVisuals ?? [];
|
||||||
|
const blockingIssueCodes = [
|
||||||
|
...new Set(getBlockingVisualDiffIssues(report).map((issue) => issue.code))
|
||||||
|
];
|
||||||
return {
|
return {
|
||||||
status: report.status,
|
status: report.status,
|
||||||
issueCodes: [...new Set(report.issues.map((issue) => issue.code))],
|
issueCodes: [...new Set(report.issues.map((issue) => issue.code))],
|
||||||
strictIssueCodes: [
|
blockingIssueCodes,
|
||||||
...new Set(
|
strictIssueCodes: blockingIssueCodes,
|
||||||
report.issues
|
|
||||||
.filter((issue) => strictIssueCodes.has(issue.code))
|
|
||||||
.map((issue) => issue.code)
|
|
||||||
)
|
|
||||||
],
|
|
||||||
editableExact: report.basic.candidateEditableExact,
|
editableExact: report.basic.candidateEditableExact,
|
||||||
paragraphIssueCount: paragraphIssues.length,
|
paragraphIssueCount: paragraphIssues.length,
|
||||||
|
semanticBlockCount: semanticBlocks.length,
|
||||||
|
failedSemanticBlockCount: semanticBlocks.filter(
|
||||||
|
(comparison) => comparison.status === "failed"
|
||||||
|
).length,
|
||||||
bodyFlow: report.basic.bodyFlow
|
bodyFlow: report.basic.bodyFlow
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -453,6 +452,30 @@ function writeReportArtifacts(directory, prefix, report) {
|
|||||||
page.artifacts.heatmapPng
|
page.artifacts.heatmapPng
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
for (const comparison of report.basic.semanticBlockVisuals ?? []) {
|
||||||
|
for (const line of comparison.lines) {
|
||||||
|
if (!line.artifacts) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const stem = `${prefix}-block-${comparison.expectation.index + 1}-line-${line.lineIndex + 1}`;
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(directory, `${stem}-baseline.png`),
|
||||||
|
line.artifacts.baselinePng
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(directory, `${stem}-candidate.png`),
|
||||||
|
line.artifacts.candidatePng
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(directory, `${stem}-overlay.png`),
|
||||||
|
line.artifacts.overlayPng
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(directory, `${stem}-heatmap.png`),
|
||||||
|
line.artifacts.heatmapPng
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.mkdirSync(outputDirectory, { recursive: true });
|
fs.mkdirSync(outputDirectory, { recursive: true });
|
||||||
|
|||||||
Reference in New Issue
Block a user