226 lines
6.7 KiB
TypeScript
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,
|
|
};
|
|
}
|