feat: 完成 DOCX 视觉门禁与字体兼容映射

This commit is contained in:
SkyJourney
2026-07-31 20:13:19 +08:00
parent 10bc62cee4
commit e5da699ea3
32 changed files with 3015 additions and 17 deletions
+296
View File
@@ -0,0 +1,296 @@
import { comparePdfSnapshotsBasic, DEFAULT_VISUAL_DIFF_THRESHOLDS } from "./compare.js";
import { comparePageRasters } from "./raster.js";
import type {
CreatePdfVisualDiffOptions,
PdfDocumentSnapshot,
PdfPagePair,
PdfVisualDiffReport,
PdfVisualPageComparison,
VisualDiffIssue,
VisualDiffThresholds,
} from "./types.js";
function resolveStatus(
issues: readonly VisualDiffIssue[],
): "passed" | "warning" | "failed" {
if (issues.some((issue) => issue.severity === "failure")) {
return "failed";
}
return issues.length > 0 ? "warning" : "passed";
}
function rasterIssues(
pair: Extract<PdfPagePair, { status: "paired" }>,
metrics: NonNullable<PdfVisualPageComparison["metrics"]>,
thresholds: VisualDiffThresholds,
): VisualDiffIssue[] {
const location = {
baselinePageNumber: pair.baselinePageNumber,
candidatePageNumber: pair.candidatePageNumber,
};
const issues: VisualDiffIssue[] = [];
if (!metrics.dimensionsMatch) {
issues.push({
code: "RASTER_SIZE_MISMATCH",
severity: "failure",
message: `${pair.baselinePageNumber} 页栅格尺寸不一致`,
...location,
details: {
comparedWidthPx: metrics.comparedWidthPx,
comparedHeightPx: metrics.comparedHeightPx,
},
});
}
if (metrics.meanAbsoluteError > thresholds.maxMeanAbsoluteError) {
issues.push({
code: "PIXEL_MAE_EXCEEDED",
severity: "failure",
message: `${pair.baselinePageNumber} 页平均像素误差 ${metrics.meanAbsoluteError.toFixed(3)} 超过 ${thresholds.maxMeanAbsoluteError}`,
...location,
details: { meanAbsoluteError: metrics.meanAbsoluteError },
});
}
if (metrics.changedPixelRatio > thresholds.maxChangedPixelRatio) {
issues.push({
code: "CHANGED_PIXEL_RATIO_EXCEEDED",
severity: "failure",
message: `${pair.baselinePageNumber} 页变化像素率 ${(metrics.changedPixelRatio * 100).toFixed(3)}% 超过 ${(thresholds.maxChangedPixelRatio * 100).toFixed(3)}%`,
...location,
details: { changedPixelRatio: metrics.changedPixelRatio },
});
}
if (metrics.inkIou < thresholds.minInkIou) {
issues.push({
code: "INK_IOU_BELOW_THRESHOLD",
severity: "failure",
message: `${pair.baselinePageNumber} 页墨迹 IoU ${metrics.inkIou.toFixed(4)} 低于 ${thresholds.minInkIou}`,
...location,
details: { inkIou: metrics.inkIou },
});
}
if (metrics.edgeIou < thresholds.minEdgeIou) {
issues.push({
code: "EDGE_IOU_BELOW_THRESHOLD",
severity: "failure",
message: `${pair.baselinePageNumber} 页边缘 IoU ${metrics.edgeIou.toFixed(4)} 低于 ${thresholds.minEdgeIou}`,
...location,
details: { edgeIou: metrics.edgeIou },
});
}
return issues;
}
async function compareVisualPage(
pair: PdfPagePair,
baseline: PdfDocumentSnapshot,
candidate: PdfDocumentSnapshot,
thresholds: VisualDiffThresholds,
): Promise<PdfVisualPageComparison> {
if (pair.status !== "paired") {
const page =
pair.status === "baseline-only"
? baseline.pages[pair.baselinePageNumber - 1]
: candidate.pages[pair.candidatePageNumber - 1];
return {
pair,
status: "failed",
...(page?.raster
? {
unpairedPng: page.raster.png,
unpairedSha256: page.raster.sha256,
}
: {}),
issues: [],
};
}
const baselinePage = baseline.pages[pair.baselinePageNumber - 1];
const candidatePage = candidate.pages[pair.candidatePageNumber - 1];
if (!baselinePage?.raster || !candidatePage?.raster) {
const issue: VisualDiffIssue = {
code: "RASTER_MISSING",
severity: "failure",
message: `${pair.baselinePageNumber} 页缺少栅格快照`,
baselinePageNumber: pair.baselinePageNumber,
candidatePageNumber: pair.candidatePageNumber,
};
return {
pair,
status: "failed",
issues: [issue],
};
}
const result = await comparePageRasters(
baselinePage.raster,
candidatePage.raster,
thresholds.pixelDifferenceThreshold,
);
const issues = rasterIssues(pair, result.metrics, thresholds);
return {
pair,
status: resolveStatus(issues),
metrics: result.metrics,
artifacts: result.artifacts,
issues,
};
}
export async function createPdfVisualDiffReport(
baseline: PdfDocumentSnapshot,
candidate: PdfDocumentSnapshot,
options: CreatePdfVisualDiffOptions = {},
): Promise<PdfVisualDiffReport> {
const thresholds = {
...DEFAULT_VISUAL_DIFF_THRESHOLDS,
...options.thresholds,
};
const basic = comparePdfSnapshotsBasic(baseline, candidate, thresholds);
const pages: PdfVisualPageComparison[] = [];
for (const pair of basic.pagePairs) {
pages.push(
await compareVisualPage(pair, baseline, candidate, thresholds),
);
}
const issues = [
...basic.issues,
...pages.flatMap((page) => page.issues),
];
return {
schemaVersion: 1,
generatedAt: options.generatedAt ?? new Date().toISOString(),
status: resolveStatus(issues),
basic,
pages,
issues,
};
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function pngDataUri(bytes: Uint8Array): string {
return `data:image/png;base64,${Buffer.from(bytes).toString("base64")}`;
}
function percentage(value: number): string {
return `${(value * 100).toFixed(3)}%`;
}
function renderMetrics(page: PdfVisualPageComparison): string {
if (!page.metrics) {
return '<p class="muted">无可比较的成对栅格。</p>';
}
const metrics = page.metrics;
return `<div class="metrics">
<div><span>MAE</span><strong>${metrics.meanAbsoluteError.toFixed(3)}</strong></div>
<div><span>变化像素</span><strong>${percentage(metrics.changedPixelRatio)}</strong></div>
<div><span>墨迹 IoU</span><strong>${metrics.inkIou.toFixed(4)}</strong></div>
<div><span>边缘 IoU</span><strong>${metrics.edgeIou.toFixed(4)}</strong></div>
</div>`;
}
function renderArtifacts(page: PdfVisualPageComparison): string {
if (page.unpairedPng) {
return `<div class="unpaired">
<figure><figcaption>未配对页面</figcaption><img src="${pngDataUri(page.unpairedPng)}" alt="未配对页面"></figure>
</div>`;
}
const artifacts = page.artifacts;
if (!artifacts) {
return "";
}
return `<div class="side-by-side">
<figure><figcaption>基线</figcaption><img src="${pngDataUri(artifacts.baselinePng)}" alt="基线页面"></figure>
<figure><figcaption>候选</figcaption><img src="${pngDataUri(artifacts.candidatePng)}" alt="候选页面"></figure>
</div>
<div class="side-by-side">
<figure><figcaption>红/青叠加</figcaption><img src="${pngDataUri(artifacts.overlayPng)}" alt="叠加差异"></figure>
<figure><figcaption>差异热力图</figcaption><img src="${pngDataUri(artifacts.heatmapPng)}" alt="差异热力图"></figure>
</div>`;
}
function pageTitle(page: PdfVisualPageComparison): string {
if (page.pair.status === "paired") {
return `基线第 ${page.pair.baselinePageNumber} 页 ↔ 候选第 ${page.pair.candidatePageNumber}`;
}
if (page.pair.status === "baseline-only") {
return `基线第 ${page.pair.baselinePageNumber} 页无配对`;
}
return `候选第 ${page.pair.candidatePageNumber} 页为溢出页`;
}
export function renderPdfVisualDiffHtml(
report: PdfVisualDiffReport,
): string {
const issueRows = report.issues.length
? report.issues
.map(
(issue) =>
`<tr><td>${escapeHtml(issue.severity)}</td><td>${escapeHtml(issue.code)}</td><td>${escapeHtml(issue.message)}</td></tr>`,
)
.join("")
: '<tr><td colspan="3">没有发现门禁问题。</td></tr>';
const pages = report.pages
.map(
(page, index) => `<section class="page-card">
<h2>${index + 1}. ${escapeHtml(pageTitle(page))} <span class="status ${page.status}">${page.status}</span></h2>
${renderMetrics(page)}
${renderArtifacts(page)}
</section>`,
)
.join("");
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PDF 视觉差异报告</title>
<style>
:root{font-family:Inter,"Microsoft YaHei",sans-serif;color:#172033;background:#f3f5f8}
*{box-sizing:border-box}body{margin:0}main{max-width:1500px;margin:auto;padding:28px}
h1,h2{margin:0 0 16px}h2{font-size:18px}.summary,.page-card{background:#fff;border:1px solid #dfe4ec;border-radius:12px;padding:20px;margin-bottom:20px;box-shadow:0 3px 12px #1720330d}
.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}
.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}
img{display:block;width:100%;height:auto;border:1px solid #d0d5dd;background:#fff}
table{width:100%;border-collapse:collapse}th,td{padding:9px;border-bottom:1px solid #e4e7ec;text-align:left;vertical-align:top}
.muted{color:#667085}@media(max-width:800px){main{padding:12px}.metrics,.side-by-side{grid-template-columns:1fr}}
</style>
</head>
<body><main>
<section class="summary">
<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>
<table><thead><tr><th>级别</th><th>代码</th><th>说明</th></tr></thead><tbody>${issueRows}</tbody></table>
</section>
${pages}
</main></body></html>`;
}
export function serializePdfVisualDiffJson(
report: PdfVisualDiffReport,
indentation = 2,
): string {
return JSON.stringify(
report,
(key, value: unknown) => {
if (
key.endsWith("Png") &&
value instanceof Uint8Array
) {
return { byteLength: value.byteLength };
}
return value;
},
indentation,
);
}