From 28c88faabb2179f9bb0d93b76c9b73ba310a8b0a Mon Sep 17 00:00:00 2001 From: SkyJourney Date: Mon, 3 Aug 2026 10:09:43 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E9=87=8D=E5=BB=BADOCX=E8=A7=86=E8=A7=89?= =?UTF-8?q?=E4=B8=80=E8=87=B4=E6=80=A7=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/PROGRESS.md | 36 ++- docs/V0.6.1_DESIGN.md | 142 +++++++++ packages/document-visual-diff/src/index.ts | 1 + .../src/paragraph-layout.ts | 43 +++ packages/document-visual-diff/src/report.ts | 95 +++++- .../src/semantic-block.ts | 272 ++++++++++++++++++ packages/document-visual-diff/src/snapshot.ts | 16 +- packages/document-visual-diff/src/types.ts | 34 ++- .../tests/paragraph-layout.test.ts | 29 ++ .../document-visual-diff/tests/report.test.ts | 115 +++++++- scripts/verify-docx-layout-visual-matrix.mjs | 77 +++-- 11 files changed, 811 insertions(+), 49 deletions(-) create mode 100644 docs/V0.6.1_DESIGN.md create mode 100644 packages/document-visual-diff/src/semantic-block.ts diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index a5072c5..d3e65eb 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1,6 +1,6 @@ # 墨呈进度 -最后更新:2026-08-02 +最后更新:2026-08-03 ## 1. 当前概况 @@ -68,6 +68,14 @@ Python;左侧编辑器计划升级为 CodeMirror 6,并通过项目自有命 支持基础工具栏及后续 Front Matter、ECharts YAML 工具扩展。完整设计与 验收门禁见 [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`,官方 Windows ZIP 和 Linux amd64 tarball 的 SHA-256 均已核验;Linux 静态程序 可直接运行于 `node:22-bookworm-slim`。Desktop 安装包预计增加约 @@ -1460,9 +1468,9 @@ ECharts 第二阶段浏览器与 PDF 验证结果: Server HTTP、Chromium PDF 与生产 DOCX 矩阵; - 阶段 12D-R4 媒体专项:已完成稳定 PNG 布局、严格 DrawingML、五媒体 Word/WPS 编辑互存、隔离逐页视觉比较和题注居中门禁; -- 阶段 12D-R4 布局视觉矩阵:已完成六组主题、纸张、方向与页边距组合的 - Chromium/Word/WPS 元素级严格门禁,并重跑 14 套主题生产矩阵;分页流 - 差异保留为诊断项,不再误判为样式失败; +- 阶段 12D-R4 布局视觉矩阵:曾记录为完成六组 Chromium/Word/WPS + 元素级严格门禁;`v0.6.1` 发布后复核确认该矩阵只覆盖五套主题,且外层 + 过滤了像素、墨迹 IoU 和边缘 IoU failure,因此不构成有效严格门禁; - 发布收口阶段 A:已完成 v0.6.0 版本、墨呈/MorphDoc 品牌、NSIS 名称、 文件关联兼容和旧版用户数据迁移; - 发布收口阶段 B:已完成 Desktop 固定 Pandoc 最小运行时的清单、下载 @@ -1473,10 +1481,28 @@ ECharts 第二阶段浏览器与 PDF 验证结果: 镜像封包、部署 ZIP、清单与校验和规则,并将正式字体纳入 Git LFS; - 阶段 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 可视化编辑工具; - ECharts YAML 可视化配置工具; diff --git a/docs/V0.6.1_DESIGN.md b/docs/V0.6.1_DESIGN.md new file mode 100644 index 0000000..b6d744e --- /dev/null +++ b/docs/V0.6.1_DESIGN.md @@ -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 逐页人工复核后才能发布。 diff --git a/packages/document-visual-diff/src/index.ts b/packages/document-visual-diff/src/index.ts index 1688420..ca533a2 100644 --- a/packages/document-visual-diff/src/index.ts +++ b/packages/document-visual-diff/src/index.ts @@ -6,6 +6,7 @@ export * from "./paragraph-layout.js"; export * from "./page-semantics.js"; export * from "./raster.js"; export * from "./report.js"; +export * from "./semantic-block.js"; export * from "./snapshot.js"; export * from "./text.js"; export * from "./types.js"; diff --git a/packages/document-visual-diff/src/paragraph-layout.ts b/packages/document-visual-diff/src/paragraph-layout.ts index 69f2668..3b9cccc 100644 --- a/packages/document-visual-diff/src/paragraph-layout.ts +++ b/packages/document-visual-diff/src/paragraph-layout.ts @@ -113,6 +113,17 @@ function observeParagraph( Array.from(lines[index]?.normalizedText ?? "").length === (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; if (matched) { cursor.lineIndex = localCursor.lineIndex; @@ -146,6 +157,7 @@ function observeParagraph( ? {} : { averageBaselineGapPt }), exclusiveGeometry, + visualLines, }; } @@ -192,6 +204,8 @@ function compareParagraph( expectation: EditableParagraphExpectation, baseline: PdfParagraphLayoutObservation, candidate: PdfParagraphLayoutObservation, + baselineCoverPageCount: number, + candidateCoverPageCount: number, ): VisualDiffIssue[] { const issues: VisualDiffIssue[] = []; 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) { @@ -373,6 +408,12 @@ export function comparePdfEditableParagraphLayouts( const candidateCharacters = candidateLines.map((line) => 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 candidateCursor: MatchCursor = { lineIndex: 0, characterIndex: 0 }; @@ -393,6 +434,8 @@ export function comparePdfEditableParagraphLayouts( expectation, baselineObservation, candidateObservation, + baselineCoverPageCount, + candidateCoverPageCount, ); return { expectation, diff --git a/packages/document-visual-diff/src/report.ts b/packages/document-visual-diff/src/report.ts index 3d72aa6..e68bab7 100644 --- a/packages/document-visual-diff/src/report.ts +++ b/packages/document-visual-diff/src/report.ts @@ -1,6 +1,7 @@ import { comparePdfSnapshotsBasic, DEFAULT_VISUAL_DIFF_THRESHOLDS } from "./compare.js"; import { comparePageRasters } from "./raster.js"; import { comparePdfPageSemantics } from "./page-semantics.js"; +import { comparePdfSemanticBlockVisuals } from "./semantic-block.js"; import type { CreatePdfVisualDiffOptions, PdfDocumentSnapshot, @@ -21,6 +22,12 @@ function resolveStatus( return issues.length > 0 ? "warning" : "passed"; } +export function getBlockingVisualDiffIssues( + report: Pick, +): VisualDiffIssue[] { + return report.issues.filter((issue) => issue.severity === "failure"); +} + function rasterIssues( pair: Extract, metrics: NonNullable, @@ -91,7 +98,7 @@ async function compareVisualPage( baseline: PdfDocumentSnapshot, candidate: PdfDocumentSnapshot, thresholds: VisualDiffThresholds, - rasterComparisonMode: "strict" | "body-flow" = "strict", + rasterComparisonMode: "strict" | "body-flow" | "body-block" = "strict", ): Promise { if (pair.status !== "paired") { const page = @@ -101,7 +108,7 @@ async function compareVisualPage( return { pair, rasterComparisonMode, - status: rasterComparisonMode === "body-flow" ? "warning" : "failed", + status: rasterComparisonMode === "strict" ? "failed" : "warning", ...(page?.raster ? { unpairedPng: page.raster.png, @@ -109,12 +116,18 @@ async function compareVisualPage( } : {}), 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", - message: "正文自然分页产生未配对物理页,保留页面快照供审查", + message: + rasterComparisonMode === "body-flow" + ? "正文自然分页产生未配对物理页,保留页面快照供审查" + : "正文物理页未配对,整页栅格仅作为诊断,最终由语义块门禁判定", ...(pair.status === "baseline-only" ? { baselinePageNumber: pair.baselinePageNumber } : { candidatePageNumber: pair.candidatePageNumber }), @@ -128,14 +141,15 @@ async function compareVisualPage( if (!baselinePage?.raster || !candidatePage?.raster) { const issue: VisualDiffIssue = { code: "RASTER_MISSING", - severity: "failure", + severity: rasterComparisonMode === "strict" ? "failure" : "warning", message: `第 ${pair.baselinePageNumber} 页缺少栅格快照`, baselinePageNumber: pair.baselinePageNumber, candidatePageNumber: pair.candidatePageNumber, }; return { pair, - status: "failed", + rasterComparisonMode, + status: rasterComparisonMode === "strict" ? "failed" : "warning", issues: [issue], }; } @@ -161,12 +175,18 @@ async function compareVisualPage( ); const strictIssues = rasterIssues(pair, result.metrics, thresholds); 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, - message: `第 ${pair.baselinePageNumber} 页栅格差异来自已验证的正文自然分页流动`, + message: + rasterComparisonMode === "body-flow" + ? `第 ${pair.baselinePageNumber} 页栅格差异来自已验证的正文自然分页流动` + : `第 ${pair.baselinePageNumber} 页为正文页,整页栅格差异仅作诊断,最终由语义块门禁判定`, baselinePageNumber: pair.baselinePageNumber, candidatePageNumber: pair.candidatePageNumber, details: { @@ -218,6 +238,26 @@ export async function createPdfVisualDiffReport( ? {} : { 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[] = []; for (const pair of basic.pagePairs) { const baselineSemanticExpectation = @@ -244,12 +284,21 @@ export async function createPdfVisualDiffReport( pair.candidatePageNumber, ))), ); + const isSemanticBodyPage = + baselineSemanticExpectation?.kind !== "cover" && + candidateSemanticExpectation?.kind !== "cover" && + Boolean(baselineSemanticExpectation || candidateSemanticExpectation) && + (basic.semanticBlockVisuals?.length ?? 0) > 0; const page = await compareVisualPage( pair, baseline, candidate, thresholds, - flowAffected ? "body-flow" : "strict", + flowAffected + ? "body-flow" + : isSemanticBodyPage + ? "body-block" + : "strict", ); const primarySemanticExpectation = baselineSemanticExpectation ?? candidateSemanticExpectation; @@ -366,6 +415,8 @@ function pageTitle(page: PdfVisualPageComparison): string { const mode = page.rasterComparisonMode === "body-flow" ? "(正文流动比较)" + : page.rasterComparisonMode === "body-block" + ? "(正文整页诊断)" : "(严格比较)"; if (page.pair.status === "paired") { return `基线第 ${page.pair.baselinePageNumber} 页 ↔ 候选第 ${page.pair.candidatePageNumber} 页${mode}`; @@ -376,6 +427,27 @@ function pageTitle(page: PdfVisualPageComparison): string { 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 `${expectation.index + 1}${escapeHtml(expectation.section)}${escapeHtml(expectation.role)}${escapeHtml(comparison.baselineFontFamilies.join(", ") || "未识别")}${escapeHtml(comparison.candidateFontFamilies.join(", ") || "未识别")}${comparison.lines.length}${escapeHtml(comparison.issues.map((issue) => issue.code).join(", ") || "通过")}`; + }) + .join(""); + return `
+

正文语义块局部视觉 ${failures.length > 0 ? "failed" : "passed"}

+

正文语义块 ${comparisons.length} 个,失败 ${failures.length} 个;块按内容顺序匹配,局部栅格参与硬门禁,物理页号不参与。PDF.js 字体分类仅作诊断,真实字体解析由独立字体门禁判定。

+ ${rows}
分节角色基线字体分类候选字体分类局部行结果
+
`; +} + function renderParagraphLayouts(report: PdfVisualDiffReport): string { const paragraphs = report.basic.paragraphLayouts; if (!paragraphs) { @@ -451,6 +523,7 @@ export function renderPdfVisualDiffHtml(

生成时间:${escapeHtml(report.generatedAt)} 正文相似度:${percentage(report.basic.contentSimilarity)}

${editableMetrics} ${renderParagraphLayouts(report)} + ${renderSemanticBlockVisuals(report)} ${issueRows}
级别代码说明
${pages} diff --git a/packages/document-visual-diff/src/semantic-block.ts b/packages/document-visual-diff/src/semantic-block.ts new file mode 100644 index 0000000..76fe959 --- /dev/null +++ b/packages/document-visual-diff/src/semantic-block.ts @@ -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 { + 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 { + 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, + 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 { + 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; +} diff --git a/packages/document-visual-diff/src/snapshot.ts b/packages/document-visual-diff/src/snapshot.ts index 9cd2b83..7d97240 100644 --- a/packages/document-visual-diff/src/snapshot.ts +++ b/packages/document-visual-diff/src/snapshot.ts @@ -21,6 +21,10 @@ interface PdfJsTextItem { hasEOL: boolean; } +interface PdfJsTextStyle { + fontFamily?: string; +} + export const DEFAULT_PDF_SNAPSHOT_LIMITS: PdfSnapshotLimits = { maxPdfBytes: 100 * 1024 * 1024, maxPages: 500, @@ -55,6 +59,7 @@ function isTextItem(item: unknown): item is PdfJsTextItem { function createTextItemSnapshot( item: PdfJsTextItem, pageHeightPt: number, + styles: Readonly>, ): PdfTextItemSnapshot | undefined { const normalizedText = normalizePdfText(item.str); if (!normalizedText) { @@ -72,6 +77,7 @@ function createTextItemSnapshot( 0, ); const baselineY = pageHeightPt - baselineFromBottom; + const fontFamily = styles[item.fontName]?.fontFamily; return { text: item.str, normalizedText, @@ -83,6 +89,7 @@ function createTextItemSnapshot( }, baselineY, fontName: item.fontName, + ...(fontFamily ? { fontFamily } : {}), hasEol: item.hasEOL, }; } @@ -138,11 +145,18 @@ export async function createPdfDocumentSnapshot( disableNormalization: false, includeMarkedContent: false, }); + const styles = textContent.styles as Readonly< + Record + >; const items = textContent.items.flatMap((item) => { if (!isTextItem(item)) { return []; } - const snapshot = createTextItemSnapshot(item, pointViewport.height); + const snapshot = createTextItemSnapshot( + item, + pointViewport.height, + styles, + ); return snapshot ? [snapshot] : []; }); const lines = aggregatePdfTextLines(items, pointViewport.height); diff --git a/packages/document-visual-diff/src/types.ts b/packages/document-visual-diff/src/types.ts index e3e16a0..59daf35 100644 --- a/packages/document-visual-diff/src/types.ts +++ b/packages/document-visual-diff/src/types.ts @@ -19,6 +19,7 @@ export interface PdfTextItemSnapshot { bounds: PdfPointBounds; baselineY: number; fontName?: string; + fontFamily?: string; hasEol: boolean; } @@ -68,6 +69,13 @@ export interface PdfParagraphLayoutObservation { averageLineHeightPt?: number; averageBaselineGapPt?: number; exclusiveGeometry: boolean; + visualLines: PdfParagraphVisualLineObservation[]; +} + +export interface PdfParagraphVisualLineObservation { + pageNumber: number; + bounds: PdfPointBounds; + fontFamilies: string[]; } export interface PdfParagraphLayoutComparison { @@ -78,6 +86,24 @@ export interface PdfParagraphLayoutComparison { 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 { detected: boolean; legal: boolean; @@ -171,9 +197,12 @@ export type VisualDiffIssueCode = | "BODY_LINE_COUNT_NEAR_BOUNDARY" | "PARAGRAPH_LINE_BREAK_MISMATCH" | "PARAGRAPH_LINE_GEOMETRY_MISMATCH" + | "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE" + | "SEMANTIC_BLOCK_RASTER_MISMATCH" | "COVER_LAYOUT_MISMATCH" | "BODY_FLOW_PAGE_COUNT_DIFFERENCE" - | "BODY_FLOW_RASTER_DIFFERENCE"; + | "BODY_FLOW_RASTER_DIFFERENCE" + | "BODY_PAGE_RASTER_DIFFERENCE"; export interface VisualDiffIssue { code: VisualDiffIssueCode; @@ -210,6 +239,7 @@ export interface PdfBasicComparison { candidateEditableSimilarity?: number; candidateEditableExact?: boolean; paragraphLayouts?: PdfParagraphLayoutComparison[]; + semanticBlockVisuals?: PdfSemanticBlockVisualComparison[]; bodyFlow?: PdfBodyFlowComparison; pagePairs: PdfPagePair[]; issues: VisualDiffIssue[]; @@ -255,7 +285,7 @@ export interface PdfRasterDiffResult { export interface PdfVisualPageComparison { pair: PdfPagePair; - rasterComparisonMode?: "strict" | "body-flow"; + rasterComparisonMode?: "strict" | "body-flow" | "body-block"; status: "passed" | "warning" | "failed"; metrics?: PdfRasterDiffMetrics; artifacts?: PdfRasterDiffArtifacts; diff --git a/packages/document-visual-diff/tests/paragraph-layout.test.ts b/packages/document-visual-diff/tests/paragraph-layout.test.ts index f43da0c..03e4d67 100644 --- a/packages/document-visual-diff/tests/paragraph-layout.test.ts +++ b/packages/document-visual-diff/tests/paragraph-layout.test.ts @@ -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 文本提取器失真的单行宽度但保留诊断值", () => { const baseline = snapshot("baseline", [ [line("数据治理平台建设项目", 300, 224)], diff --git a/packages/document-visual-diff/tests/report.test.ts b/packages/document-visual-diff/tests/report.test.ts index 5353582..4f05055 100644 --- a/packages/document-visual-diff/tests/report.test.ts +++ b/packages/document-visual-diff/tests/report.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { createPdfVisualDiffReport, + getBlockingVisualDiffIssues, renderPdfVisualDiffHtml, serializePdfVisualDiffJson, 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 { return { schemaVersion: 1, @@ -82,7 +117,7 @@ function flowSnapshot( items: [], lines: [...lines], 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( "baseline", [[contentLine("甲", 8)], [contentLine("乙丙", 8)]], @@ -411,7 +446,7 @@ describe("PDF 视觉差异报告", () => { expect(report.status).toBe("failed"); expect(report.basic.bodyFlow).toMatchObject({ detected: true, legal: false }); expect(report.pages.every( - (page) => page.rasterComparisonMode === "strict", + (page) => page.rasterComparisonMode === "body-block", )).toBe(true); expect(report.issues.map((issue) => issue.code)).toContain( "TEXT_BLOCK_LINE_COUNT_MISMATCH", @@ -498,4 +533,78 @@ describe("PDF 视觉差异报告", () => { "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([]); + }); }); diff --git a/scripts/verify-docx-layout-visual-matrix.mjs b/scripts/verify-docx-layout-visual-matrix.mjs index 166aa71..26d2984 100644 --- a/scripts/verify-docx-layout-visual-matrix.mjs +++ b/scripts/verify-docx-layout-visual-matrix.mjs @@ -10,6 +10,7 @@ import { createPdfVisualDiffReport, createWordPdfAdapter, createWpsPdfAdapter, + getBlockingVisualDiffIssues, renderPdfVisualDiffHtml, serializePdfVisualDiffJson } 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] }, 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 = [ { 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", themeId: "formal-regulation", @@ -379,8 +376,7 @@ function buildPageSemantics(pageCount, coverPageCount, config) { } function reportGateFailures(report, label) { - const failures = report.issues - .filter((issue) => strictIssueCodes.has(issue.code)) + const failures = getBlockingVisualDiffIssues(report) .map((issue) => `${label}: ${issue.code} - ${issue.message}`); if (report.basic.baselineEditableCoverage !== 1) { failures.push( @@ -400,18 +396,21 @@ function reportSummary(report) { const paragraphIssues = report.basic.paragraphLayouts?.flatMap( (paragraph) => paragraph.issues ) ?? []; + const semanticBlocks = report.basic.semanticBlockVisuals ?? []; + const blockingIssueCodes = [ + ...new Set(getBlockingVisualDiffIssues(report).map((issue) => issue.code)) + ]; return { status: report.status, issueCodes: [...new Set(report.issues.map((issue) => issue.code))], - strictIssueCodes: [ - ...new Set( - report.issues - .filter((issue) => strictIssueCodes.has(issue.code)) - .map((issue) => issue.code) - ) - ], + blockingIssueCodes, + strictIssueCodes: blockingIssueCodes, editableExact: report.basic.candidateEditableExact, paragraphIssueCount: paragraphIssues.length, + semanticBlockCount: semanticBlocks.length, + failedSemanticBlockCount: semanticBlocks.filter( + (comparison) => comparison.status === "failed" + ).length, bodyFlow: report.basic.bodyFlow }; } @@ -453,6 +452,30 @@ function writeReportArtifacts(directory, prefix, report) { 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 });