feat: 完成 DOCX R4 元素级视觉门禁
建立 Chromium、Word 与 WPS 的可编辑内容、段落几何、页眉页脚、封面、媒体和逐页视觉比较链路。 支持封面独立分节、正文页码重启、主题页边距与横向纸张,并将自然分页差异降为诊断项。 修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user