fix: 重建DOCX视觉一致性门禁

This commit is contained in:
SkyJourney
2026-08-03 10:09:43 +08:00
parent 842c3f1782
commit 28c88faabb
11 changed files with 811 additions and 49 deletions
+84 -11
View File
@@ -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<PdfVisualDiffReport, "issues">,
): VisualDiffIssue[] {
return report.issues.filter((issue) => issue.severity === "failure");
}
function rasterIssues(
pair: Extract<PdfPagePair, { status: "paired" }>,
metrics: NonNullable<PdfVisualPageComparison["metrics"]>,
@@ -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<PdfVisualPageComparison> {
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 `<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 {
const paragraphs = report.basic.paragraphLayouts;
if (!paragraphs) {
@@ -451,6 +523,7 @@ export function renderPdfVisualDiffHtml(
<p><strong>生成时间:</strong>${escapeHtml(report.generatedAt)} <strong>正文相似度:</strong>${percentage(report.basic.contentSimilarity)}</p>
${editableMetrics}
${renderParagraphLayouts(report)}
${renderSemanticBlockVisuals(report)}
<table><thead><tr><th>级别</th><th>代码</th><th>说明</th></tr></thead><tbody>${issueRows}</tbody></table>
</section>
${pages}