feat: 完成 DOCX 视觉门禁与字体兼容映射
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user