import { normalizePdfContentText } from "./text.js"; import type { PdfBasicComparison, PdfDocumentSnapshot, PdfPagePair, VisualDiffIssue, VisualDiffThresholds, } from "./types.js"; export const DEFAULT_VISUAL_DIFF_THRESHOLDS: VisualDiffThresholds = { pageSizeDeltaPt: 0.5, contentSimilarity: 0.999, strictPageCount: true, pixelDifferenceThreshold: 8, maxMeanAbsoluteError: 8, maxChangedPixelRatio: 0.05, minInkIou: 0.75, minEdgeIou: 0.6, }; function cappedLevenshteinDistance( left: string, right: string, cap: number, ): number { if (left === right) { return 0; } if (Math.abs(left.length - right.length) > cap) { return cap + 1; } let previous = new Int32Array(right.length + 1); let current = new Int32Array(right.length + 1); for (let column = 0; column <= right.length; column += 1) { previous[column] = column; } for (let row = 1; row <= left.length; row += 1) { current.fill(cap + 1); current[0] = row; const start = Math.max(1, row - cap); const end = Math.min(right.length, row + cap); let rowMinimum = cap + 1; for (let column = start; column <= end; column += 1) { const substitutionCost = left.charCodeAt(row - 1) === right.charCodeAt(column - 1) ? 0 : 1; const value = Math.min( (previous[column] ?? cap + 1) + 1, (current[column - 1] ?? cap + 1) + 1, (previous[column - 1] ?? cap + 1) + substitutionCost, ); current[column] = value; rowMinimum = Math.min(rowMinimum, value); } if (rowMinimum > cap) { return cap + 1; } [previous, current] = [current, previous]; } return Math.min(previous[right.length] ?? cap + 1, cap + 1); } export function calculateContentSimilarity( leftInput: string, rightInput: string, requiredSimilarity = DEFAULT_VISUAL_DIFF_THRESHOLDS.contentSimilarity, ): number { const left = normalizePdfContentText(leftInput); const right = normalizePdfContentText(rightInput); const maximumLength = Math.max(left.length, right.length); if (maximumLength === 0) { return 1; } const cap = Math.max( 1, Math.ceil(maximumLength * (1 - requiredSimilarity)), ); const distance = cappedLevenshteinDistance(left, right, cap); return Math.max(0, 1 - distance / maximumLength); } function createPagePairs( baseline: PdfDocumentSnapshot, candidate: PdfDocumentSnapshot, ): PdfPagePair[] { const pairCount = Math.max(baseline.pageCount, candidate.pageCount); return Array.from({ length: pairCount }, (_, index) => { const baselinePageNumber = index < baseline.pageCount ? index + 1 : undefined; const candidatePageNumber = index < candidate.pageCount ? index + 1 : undefined; if (baselinePageNumber === undefined) { if (candidatePageNumber === undefined) { throw new Error("页面配对状态无效"); } return { candidatePageNumber, status: "candidate-only" as const, }; } if (candidatePageNumber === undefined) { return { baselinePageNumber, status: "baseline-only" as const, }; } return { baselinePageNumber, candidatePageNumber, status: "paired" as const, }; }); } export function comparePdfSnapshotsBasic( baseline: PdfDocumentSnapshot, candidate: PdfDocumentSnapshot, thresholdOverrides: Partial = {}, ): PdfBasicComparison { const thresholds = { ...DEFAULT_VISUAL_DIFF_THRESHOLDS, ...thresholdOverrides, }; const issues: VisualDiffIssue[] = []; const pagePairs = createPagePairs(baseline, candidate); if (baseline.pageCount !== candidate.pageCount) { issues.push({ code: "PAGE_COUNT_MISMATCH", severity: thresholds.strictPageCount ? "failure" : "warning", message: `页数不一致:基线 ${baseline.pageCount} 页,候选 ${candidate.pageCount} 页`, details: { baselinePageCount: baseline.pageCount, candidatePageCount: candidate.pageCount, }, }); } for (const pair of pagePairs) { if (pair.status === "baseline-only") { issues.push({ code: "UNPAIRED_BASELINE_PAGE", severity: "failure", message: `基线第 ${pair.baselinePageNumber} 页没有候选配对页`, baselinePageNumber: pair.baselinePageNumber, }); continue; } if (pair.status === "candidate-only") { issues.push({ code: "UNPAIRED_CANDIDATE_PAGE", severity: "failure", message: `候选第 ${pair.candidatePageNumber} 页为溢出页`, candidatePageNumber: pair.candidatePageNumber, }); continue; } const baselinePage = baseline.pages[pair.baselinePageNumber - 1]; const candidatePage = candidate.pages[pair.candidatePageNumber - 1]; if (!baselinePage || !candidatePage) { continue; } const widthDelta = Math.abs( baselinePage.widthPt - candidatePage.widthPt, ); const heightDelta = Math.abs( baselinePage.heightPt - candidatePage.heightPt, ); if ( widthDelta > thresholds.pageSizeDeltaPt || heightDelta > thresholds.pageSizeDeltaPt ) { issues.push({ code: "PAGE_SIZE_MISMATCH", severity: "failure", message: `第 ${pair.baselinePageNumber} 页纸张尺寸偏差超限`, baselinePageNumber: pair.baselinePageNumber, candidatePageNumber: pair.candidatePageNumber, details: { widthDeltaPt: widthDelta, heightDeltaPt: heightDelta }, }); } const baselineLandscape = baselinePage.widthPt > baselinePage.heightPt; const candidateLandscape = candidatePage.widthPt > candidatePage.heightPt; if (baselineLandscape !== candidateLandscape) { issues.push({ code: "PAGE_ORIENTATION_MISMATCH", severity: "failure", message: `第 ${pair.baselinePageNumber} 页方向不一致`, baselinePageNumber: pair.baselinePageNumber, candidatePageNumber: pair.candidatePageNumber, }); } } const contentSimilarity = calculateContentSimilarity( baseline.contentText, candidate.contentText, thresholds.contentSimilarity, ); if (contentSimilarity < thresholds.contentSimilarity) { issues.push({ code: "CONTENT_MISMATCH", severity: "failure", message: `正文文本相似度 ${(contentSimilarity * 100).toFixed(3)}% 低于门限 ${(thresholds.contentSimilarity * 100).toFixed(3)}%`, details: { contentSimilarity }, }); } const status = issues.some((issue) => issue.severity === "failure") ? "failed" : issues.length > 0 ? "warning" : "passed"; return { schemaVersion: 1, status, baseline: baseline.source, candidate: candidate.source, thresholds, contentSimilarity, pagePairs, issues, }; }