Files
MorphDoc/packages/document-visual-diff/src/compare.ts
T

226 lines
6.7 KiB
TypeScript

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<VisualDiffThresholds> = {},
): 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,
};
}