feat: 完成 DOCX R4 元素级视觉门禁

建立 Chromium、Word 与 WPS 的可编辑内容、段落几何、页眉页脚、封面、媒体和逐页视觉比较链路。

支持封面独立分节、正文页码重启、主题页边距与横向纸张,并将自然分页差异降为诊断项。

修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。
This commit is contained in:
SkyJourney
2026-08-02 03:27:11 +08:00
parent f9f5fccfc9
commit 58f87cc19f
100 changed files with 10409 additions and 386 deletions
+192 -11
View File
@@ -1,8 +1,10 @@
import { comparePdfSnapshotsBasic, DEFAULT_VISUAL_DIFF_THRESHOLDS } from "./compare.js";
import { comparePageRasters } from "./raster.js";
import { comparePdfPageSemantics } from "./page-semantics.js";
import type {
CreatePdfVisualDiffOptions,
PdfDocumentSnapshot,
PdfPageDecorationObservation,
PdfPagePair,
PdfVisualDiffReport,
PdfVisualPageComparison,
@@ -29,13 +31,17 @@ function rasterIssues(
candidatePageNumber: pair.candidatePageNumber,
};
const issues: VisualDiffIssue[] = [];
if (!metrics.dimensionsMatch) {
if (!metrics.dimensionsMatch && !metrics.geometryNormalized) {
issues.push({
code: "RASTER_SIZE_MISMATCH",
severity: "failure",
message: `${pair.baselinePageNumber} 页栅格尺寸不一致`,
...location,
details: {
baselineWidthPx: metrics.baselineWidthPx,
baselineHeightPx: metrics.baselineHeightPx,
candidateWidthPx: metrics.candidateWidthPx,
candidateHeightPx: metrics.candidateHeightPx,
comparedWidthPx: metrics.comparedWidthPx,
comparedHeightPx: metrics.comparedHeightPx,
},
@@ -85,6 +91,7 @@ async function compareVisualPage(
baseline: PdfDocumentSnapshot,
candidate: PdfDocumentSnapshot,
thresholds: VisualDiffThresholds,
rasterComparisonMode: "strict" | "body-flow" = "strict",
): Promise<PdfVisualPageComparison> {
if (pair.status !== "paired") {
const page =
@@ -93,14 +100,27 @@ async function compareVisualPage(
: candidate.pages[pair.candidatePageNumber - 1];
return {
pair,
status: "failed",
rasterComparisonMode,
status: rasterComparisonMode === "body-flow" ? "warning" : "failed",
...(page?.raster
? {
unpairedPng: page.raster.png,
unpairedSha256: page.raster.sha256,
}
: {}),
issues: [],
issues:
rasterComparisonMode === "body-flow"
? [
{
code: "BODY_FLOW_RASTER_DIFFERENCE",
severity: "warning",
message: "正文自然分页产生未配对物理页,保留页面快照供审查",
...(pair.status === "baseline-only"
? { baselinePageNumber: pair.baselinePageNumber }
: { candidatePageNumber: pair.candidatePageNumber }),
},
]
: [],
};
}
const baselinePage = baseline.pages[pair.baselinePageNumber - 1];
@@ -122,11 +142,48 @@ async function compareVisualPage(
const result = await comparePageRasters(
baselinePage.raster,
candidatePage.raster,
thresholds.pixelDifferenceThreshold,
{
pixelDifferenceThreshold: thresholds.pixelDifferenceThreshold,
...(Math.abs(baselinePage.widthPt - candidatePage.widthPt) <=
thresholds.pageSizeDeltaPt &&
Math.abs(baselinePage.heightPt - candidatePage.heightPt) <=
thresholds.pageSizeDeltaPt &&
baselinePage.rotation === candidatePage.rotation
? {
targetWidthPx: baselinePage.raster.widthPx,
targetHeightPx: baselinePage.raster.heightPx,
geometryNormalized:
baselinePage.raster.widthPx !== candidatePage.raster.widthPx ||
baselinePage.raster.heightPx !== candidatePage.raster.heightPx,
}
: {}),
},
);
const issues = rasterIssues(pair, result.metrics, thresholds);
const strictIssues = rasterIssues(pair, result.metrics, thresholds);
const issues =
rasterComparisonMode === "body-flow" && strictIssues.length > 0
? [
{
code: "BODY_FLOW_RASTER_DIFFERENCE" as const,
severity: "warning" as const,
message: `${pair.baselinePageNumber} 页栅格差异来自已验证的正文自然分页流动`,
baselinePageNumber: pair.baselinePageNumber,
candidatePageNumber: pair.candidatePageNumber,
details: {
strictIssueCodes: strictIssues
.map((issue) => issue.code)
.join(","),
meanAbsoluteError: result.metrics.meanAbsoluteError,
changedPixelRatio: result.metrics.changedPixelRatio,
inkIou: result.metrics.inkIou,
edgeIou: result.metrics.edgeIou,
},
},
]
: strictIssues;
return {
pair,
rasterComparisonMode,
status: resolveStatus(issues),
metrics: result.metrics,
artifacts: result.artifacts,
@@ -143,12 +200,71 @@ export async function createPdfVisualDiffReport(
...DEFAULT_VISUAL_DIFF_THRESHOLDS,
...options.thresholds,
};
const basic = comparePdfSnapshotsBasic(baseline, candidate, thresholds);
const baselinePageSemantics =
options.baselinePageSemantics ?? options.pageSemantics ?? [];
const candidatePageSemantics =
options.candidatePageSemantics ?? options.pageSemantics ?? [];
const basic = comparePdfSnapshotsBasic(baseline, candidate, thresholds, {
...(options.expectedEditableText === undefined
? {}
: { expectedEditableText: options.expectedEditableText }),
...(baselinePageSemantics.length === 0
? {}
: { baselinePageSemantics }),
...(candidatePageSemantics.length === 0
? {}
: { candidatePageSemantics }),
...(options.expectedEditableParagraphs === undefined
? {}
: { expectedEditableParagraphs: options.expectedEditableParagraphs }),
});
const pages: PdfVisualPageComparison[] = [];
for (const pair of basic.pagePairs) {
pages.push(
await compareVisualPage(pair, baseline, candidate, thresholds),
const baselineSemanticExpectation =
pair.status === "candidate-only"
? undefined
: baselinePageSemantics.find(
(item) => item.physicalPageNumber === pair.baselinePageNumber,
);
const candidateSemanticExpectation =
pair.status === "baseline-only"
? undefined
: candidatePageSemantics.find(
(item) => item.physicalPageNumber === pair.candidatePageNumber,
);
const bodyFlow = basic.bodyFlow;
const flowAffected = Boolean(
bodyFlow?.legal &&
((pair.status !== "candidate-only" &&
bodyFlow.affectedBaselinePageNumbers.includes(
pair.baselinePageNumber,
)) ||
(pair.status !== "baseline-only" &&
bodyFlow.affectedCandidatePageNumbers.includes(
pair.candidatePageNumber,
))),
);
const page = await compareVisualPage(
pair,
baseline,
candidate,
thresholds,
flowAffected ? "body-flow" : "strict",
);
const primarySemanticExpectation =
baselineSemanticExpectation ?? candidateSemanticExpectation;
if (primarySemanticExpectation) {
const semantics = comparePdfPageSemantics(
baseline,
candidate,
primarySemanticExpectation,
candidateSemanticExpectation ?? primarySemanticExpectation,
);
page.semantics = semantics;
page.issues.push(...semantics.issues);
page.status = resolveStatus(page.issues);
}
pages.push(page);
}
const issues = [
...basic.issues,
@@ -214,19 +330,80 @@ function renderArtifacts(page: PdfVisualPageComparison): string {
</div>`;
}
function renderSemanticObservation(
label: string,
observation: PdfPageDecorationObservation | undefined,
): string {
if (!observation) {
return `<td>${escapeHtml(label)}:无页面</td>`;
}
return `<td><strong>${escapeHtml(label)}</strong><br>页码:${escapeHtml(observation.pageNumberText ?? "无")}<br>位置:${escapeHtml(observation.pageNumberAlignment ?? "无")}<br>匹配页眉槽:${observation.matchedHeaderSlots.length}<br>缺失页眉槽:${observation.missingHeaderSlots.length}</td>`;
}
function renderSemantics(page: PdfVisualPageComparison): string {
const semantics = page.semantics;
if (!semantics) {
return "";
}
const expected = semantics.expectation;
const candidateExpected = semantics.candidateExpectation ?? expected;
const kindLabels = {
cover: "封面",
"body-first": "正文首页",
"body-rest": "正文后续页",
} as const;
return `<div class="semantics">
<h3>页面语义 <span class="status ${semantics.status}">${semantics.status}</span></h3>
<table><tbody>
<tr><th>基线预期</th><td>物理第 ${expected.physicalPageNumber} 页;${kindLabels[expected.kind]};逻辑页 ${expected.logicalPageNumber ?? "—"} / ${expected.logicalPageCount};页眉 ${expected.headerVisible ? "显示" : "隐藏"};页码 ${expected.footerVisible ? `${escapeHtml(expected.pageNumberText ?? "显示")}${escapeHtml(expected.footerAlignment ?? "未指定位置")}` : "隐藏"}</td></tr>
<tr><th>候选预期</th><td>物理第 ${candidateExpected.physicalPageNumber} 页;${kindLabels[candidateExpected.kind]};逻辑页 ${candidateExpected.logicalPageNumber ?? "—"} / ${candidateExpected.logicalPageCount};页眉 ${candidateExpected.headerVisible ? "显示" : "隐藏"};页码 ${candidateExpected.footerVisible ? `${escapeHtml(candidateExpected.pageNumberText ?? "显示")}${escapeHtml(candidateExpected.footerAlignment ?? "未指定位置")}` : "隐藏"}</td></tr>
<tr><th>观测</th>${renderSemanticObservation("基线", semantics.baseline)}${renderSemanticObservation("候选", semantics.candidate)}</tr>
</tbody></table>
</div>`;
}
function pageTitle(page: PdfVisualPageComparison): string {
const mode =
page.rasterComparisonMode === "body-flow"
? "(正文流动比较)"
: "(严格比较)";
if (page.pair.status === "paired") {
return `基线第 ${page.pair.baselinePageNumber} 页 ↔ 候选第 ${page.pair.candidatePageNumber}`;
return `基线第 ${page.pair.baselinePageNumber} 页 ↔ 候选第 ${page.pair.candidatePageNumber}${mode}`;
}
if (page.pair.status === "baseline-only") {
return `基线第 ${page.pair.baselinePageNumber} 页无配对`;
return `基线第 ${page.pair.baselinePageNumber} 页无配对${mode}`;
}
return `候选第 ${page.pair.candidatePageNumber} 页为溢出页`;
return `候选第 ${page.pair.candidatePageNumber} 页为溢出页${mode}`;
}
function renderParagraphLayouts(report: PdfVisualDiffReport): string {
const paragraphs = report.basic.paragraphLayouts;
if (!paragraphs) {
return "";
}
const failures = paragraphs.filter(
(paragraph) => paragraph.status === "failed",
);
const rows = (failures.length > 0 ? failures : paragraphs.slice(0, 8))
.map((paragraph) => {
const expectation = paragraph.expectation;
return `<tr><td>${expectation.index + 1}</td><td>${escapeHtml(expectation.section)}</td><td>${escapeHtml(expectation.role)}</td><td>${escapeHtml(expectation.styleId ?? "—")}</td><td>${paragraph.baseline.lineCount}</td><td>${paragraph.candidate.lineCount}</td><td>${escapeHtml(paragraph.issues.map((issue) => issue.code).join(", ") || "通过")}</td></tr>`;
})
.join("");
return `<div class="paragraph-layouts">
<h2>内容感知行版式 <span class="status ${failures.length > 0 ? "failed" : "passed"}">${failures.length > 0 ? "failed" : "passed"}</span></h2>
<p>段落 ${paragraphs.length} 个,失败 ${failures.length} 个;正文跨页移动不参与失败判定,封面页位置严格校验。</p>
<table><thead><tr><th>段落</th><th>分节</th><th>角色</th><th>Word 样式</th><th>基线行数</th><th>候选行数</th><th>结果</th></tr></thead><tbody>${rows}</tbody></table>
</div>`;
}
export function renderPdfVisualDiffHtml(
report: PdfVisualDiffReport,
): string {
const editableMetrics =
report.basic.baselineEditableCoverage === undefined
? ""
: `<p><strong>Chromium 可编辑正文覆盖:</strong>${percentage(report.basic.baselineEditableCoverage)} <strong>候选可编辑正文相似度:</strong>${percentage(report.basic.candidateEditableSimilarity ?? 0)} <strong>候选精确匹配:</strong>${report.basic.candidateEditableExact ? "是" : "否"}</p>`;
const issueRows = report.issues.length
? report.issues
.map(
@@ -239,6 +416,7 @@ export function renderPdfVisualDiffHtml(
.map(
(page, index) => `<section class="page-card">
<h2>${index + 1}. ${escapeHtml(pageTitle(page))} <span class="status ${page.status}">${page.status}</span></h2>
${renderSemantics(page)}
${renderMetrics(page)}
${renderArtifacts(page)}
</section>`,
@@ -257,6 +435,7 @@ export function renderPdfVisualDiffHtml(
.status{display:inline-block;border-radius:999px;padding:4px 10px;font-size:12px;text-transform:uppercase}
.passed{background:#dff7e8;color:#126636}.warning{background:#fff1c7;color:#805d00}.failed{background:#ffe0e0;color:#9a1b1b}
.metrics{display:grid;grid-template-columns:repeat(4,minmax(120px,1fr));gap:10px;margin-bottom:16px}
.semantics{margin:0 0 16px;padding:14px;background:#f7f9fc;border-radius:8px}.semantics h3{margin:0 0 10px;font-size:15px}.semantics th{width:90px}
.metrics div{padding:12px;background:#f7f9fc;border-radius:8px}.metrics span{display:block;color:#667085;font-size:12px}.metrics strong{font-size:20px}
.side-by-side{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px}.unpaired{max-width:720px;margin-top:16px}
figure{margin:0;min-width:0}figcaption{margin-bottom:8px;color:#475467;font-weight:600}
@@ -270,6 +449,8 @@ export function renderPdfVisualDiffHtml(
<h1>PDF 视觉差异报告 <span class="status ${report.status}">${report.status}</span></h1>
<p><strong>基线:</strong>${escapeHtml(report.basic.baseline.label)} <strong>候选:</strong>${escapeHtml(report.basic.candidate.label)}</p>
<p><strong>生成时间:</strong>${escapeHtml(report.generatedAt)} <strong>正文相似度:</strong>${percentage(report.basic.contentSimilarity)}</p>
${editableMetrics}
${renderParagraphLayouts(report)}
<table><thead><tr><th>级别</th><th>代码</th><th>说明</th></tr></thead><tbody>${issueRows}</tbody></table>
</section>
${pages}