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, PdfPageDecorationObservation, 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"; } export function getBlockingVisualDiffIssues( report: Pick, ): VisualDiffIssue[] { return report.issues.filter((issue) => issue.severity === "failure"); } function rasterIssues( pair: Extract, metrics: NonNullable, thresholds: VisualDiffThresholds, ): VisualDiffIssue[] { const location = { baselinePageNumber: pair.baselinePageNumber, candidatePageNumber: pair.candidatePageNumber, }; const issues: VisualDiffIssue[] = []; 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, }, }); } 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, rasterComparisonMode: "strict" | "body-flow" | "body-block" = "strict", ): Promise { if (pair.status !== "paired") { const page = pair.status === "baseline-only" ? baseline.pages[pair.baselinePageNumber - 1] : candidate.pages[pair.candidatePageNumber - 1]; return { pair, rasterComparisonMode, status: rasterComparisonMode === "strict" ? "failed" : "warning", ...(page?.raster ? { unpairedPng: page.raster.png, unpairedSha256: page.raster.sha256, } : {}), issues: rasterComparisonMode !== "strict" ? [ { code: rasterComparisonMode === "body-flow" ? "BODY_FLOW_RASTER_DIFFERENCE" : "BODY_PAGE_RASTER_DIFFERENCE", severity: "warning", message: rasterComparisonMode === "body-flow" ? "正文自然分页产生未配对物理页,保留页面快照供审查" : "正文物理页未配对,整页栅格仅作为诊断,最终由语义块门禁判定", ...(pair.status === "baseline-only" ? { baselinePageNumber: pair.baselinePageNumber } : { candidatePageNumber: pair.candidatePageNumber }), }, ] : [], }; } 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: rasterComparisonMode === "strict" ? "failure" : "warning", message: `第 ${pair.baselinePageNumber} 页缺少栅格快照`, baselinePageNumber: pair.baselinePageNumber, candidatePageNumber: pair.candidatePageNumber, }; return { pair, rasterComparisonMode, status: rasterComparisonMode === "strict" ? "failed" : "warning", issues: [issue], }; } const result = await comparePageRasters( baselinePage.raster, candidatePage.raster, { pixelDifferenceThreshold: thresholds.pixelDifferenceThreshold, spatialTolerancePx: thresholds.spatialTolerancePx, ...(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 strictIssues = rasterIssues(pair, result.metrics, thresholds); const issues = rasterComparisonMode !== "strict" && strictIssues.length > 0 ? [ { code: rasterComparisonMode === "body-flow" ? ("BODY_FLOW_RASTER_DIFFERENCE" as const) : ("BODY_PAGE_RASTER_DIFFERENCE" as const), severity: "warning" as const, message: rasterComparisonMode === "body-flow" ? `第 ${pair.baselinePageNumber} 页栅格差异来自已验证的正文自然分页流动` : `第 ${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, issues, }; } export async function createPdfVisualDiffReport( baseline: PdfDocumentSnapshot, candidate: PdfDocumentSnapshot, options: CreatePdfVisualDiffOptions = {}, ): Promise { const thresholds = { ...DEFAULT_VISUAL_DIFF_THRESHOLDS, ...options.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 }), }); 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 = 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 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" : isSemanticBodyPage ? "body-block" : "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, ...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 '

无可比较的成对栅格。

'; } const metrics = page.metrics; return `
MAE${metrics.meanAbsoluteError.toFixed(3)}
变化像素${percentage(metrics.changedPixelRatio)}
墨迹 IoU${metrics.inkIou.toFixed(4)}
边缘 IoU${metrics.edgeIou.toFixed(4)}
`; } function renderArtifacts(page: PdfVisualPageComparison): string { if (page.unpairedPng) { return `
未配对页面
未配对页面
`; } const artifacts = page.artifacts; if (!artifacts) { return ""; } return `
基线
基线页面
候选
候选页面
红/青叠加
叠加差异
差异热力图
差异热力图
`; } function renderSemanticObservation( label: string, observation: PdfPageDecorationObservation | undefined, ): string { if (!observation) { return `${escapeHtml(label)}:无页面`; } return `${escapeHtml(label)}
页码:${escapeHtml(observation.pageNumberText ?? "无")}
位置:${escapeHtml(observation.pageNumberAlignment ?? "无")}
匹配页眉槽:${observation.matchedHeaderSlots.length}
缺失页眉槽:${observation.missingHeaderSlots.length}`; } 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 `

页面语义 ${semantics.status}

${renderSemanticObservation("基线", semantics.baseline)}${renderSemanticObservation("候选", semantics.candidate)}
基线预期物理第 ${expected.physicalPageNumber} 页;${kindLabels[expected.kind]};逻辑页 ${expected.logicalPageNumber ?? "—"} / ${expected.logicalPageCount};页眉 ${expected.headerVisible ? "显示" : "隐藏"};页码 ${expected.footerVisible ? `${escapeHtml(expected.pageNumberText ?? "显示")}(${escapeHtml(expected.footerAlignment ?? "未指定位置")})` : "隐藏"}
候选预期物理第 ${candidateExpected.physicalPageNumber} 页;${kindLabels[candidateExpected.kind]};逻辑页 ${candidateExpected.logicalPageNumber ?? "—"} / ${candidateExpected.logicalPageCount};页眉 ${candidateExpected.headerVisible ? "显示" : "隐藏"};页码 ${candidateExpected.footerVisible ? `${escapeHtml(candidateExpected.pageNumberText ?? "显示")}(${escapeHtml(candidateExpected.footerAlignment ?? "未指定位置")})` : "隐藏"}
观测
`; } 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}`; } if (page.pair.status === "baseline-only") { return `基线第 ${page.pair.baselinePageNumber} 页无配对${mode}`; } 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 `${expectation.index + 1}${escapeHtml(expectation.section)}${escapeHtml(expectation.role)}${escapeHtml(comparison.baselineFontFamilies.join(", ") || "未识别")}${escapeHtml(comparison.candidateFontFamilies.join(", ") || "未识别")}${comparison.lines.length}${escapeHtml(comparison.issues.map((issue) => issue.code).join(", ") || "通过")}`; }) .join(""); return `

正文语义块局部视觉 ${failures.length > 0 ? "failed" : "passed"}

正文语义块 ${comparisons.length} 个,失败 ${failures.length} 个;块按内容顺序匹配,局部栅格参与硬门禁,物理页号不参与。PDF.js 字体分类仅作诊断,真实字体解析由独立字体门禁判定。

${rows}
分节角色基线字体分类候选字体分类局部行结果
`; } 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 `${expectation.index + 1}${escapeHtml(expectation.section)}${escapeHtml(expectation.blockKind ?? expectation.role)}${escapeHtml(expectation.styleId ?? "—")}${paragraph.baseline.lineCount}${paragraph.candidate.lineCount}${escapeHtml(paragraph.issues.map((issue) => issue.code).join(", ") || "通过")}`; }) .join(""); return `

内容感知行版式 ${failures.length > 0 ? "failed" : "passed"}

段落 ${paragraphs.length} 个,失败 ${failures.length} 个;正文跨页移动不参与失败判定,封面页位置严格校验。

${rows}
段落分节角色Word 样式基线行数候选行数结果
`; } export function renderPdfVisualDiffHtml( report: PdfVisualDiffReport, ): string { const editableMetrics = report.basic.baselineEditableCoverage === undefined ? "" : `

Chromium 可编辑正文覆盖:${percentage(report.basic.baselineEditableCoverage)} 候选可编辑正文相似度:${percentage(report.basic.candidateEditableSimilarity ?? 0)} 候选精确匹配:${report.basic.candidateEditableExact ? "是" : "否"}

`; const issueRows = report.issues.length ? report.issues .map( (issue) => `${escapeHtml(issue.severity)}${escapeHtml(issue.code)}${escapeHtml(issue.message)}`, ) .join("") : '没有发现门禁问题。'; const pages = report.pages .map( (page, index) => `

${index + 1}. ${escapeHtml(pageTitle(page))} ${page.status}

${renderSemantics(page)} ${renderMetrics(page)} ${renderArtifacts(page)}
`, ) .join(""); return ` PDF 视觉差异报告

PDF 视觉差异报告 ${report.status}

基线:${escapeHtml(report.basic.baseline.label)} 候选:${escapeHtml(report.basic.candidate.label)}

生成时间:${escapeHtml(report.generatedAt)} 正文相似度:${percentage(report.basic.contentSimilarity)}

${editableMetrics} ${renderParagraphLayouts(report)} ${renderSemanticBlockVisuals(report)} ${issueRows}
级别代码说明
${pages}
`; } 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, ); }