feat: 完成 DOCX 视觉门禁与字体兼容映射
This commit is contained in:
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user