fix: 重建DOCX视觉一致性门禁
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { createCanvas, loadImage } from "@napi-rs/canvas";
|
||||
|
||||
import { comparePageRasters } from "./raster.js";
|
||||
import type {
|
||||
PdfDocumentSnapshot,
|
||||
PdfPageRaster,
|
||||
PdfParagraphLayoutComparison,
|
||||
PdfPointBounds,
|
||||
PdfSemanticBlockVisualComparison,
|
||||
PdfSemanticBlockVisualLineComparison,
|
||||
VisualDiffIssue,
|
||||
VisualDiffThresholds,
|
||||
} from "./types.js";
|
||||
|
||||
const BLOCK_RASTER_PADDING_PT = 4;
|
||||
|
||||
function sha256(bytes: Uint8Array): string {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
function status(issues: readonly VisualDiffIssue[]) {
|
||||
return issues.some((issue) => issue.severity === "failure")
|
||||
? ("failed" as const)
|
||||
: issues.length > 0
|
||||
? ("warning" as const)
|
||||
: ("passed" as const);
|
||||
}
|
||||
|
||||
function normalizeFontFamily(value: string): string {
|
||||
return value
|
||||
.normalize("NFKC")
|
||||
.replace(/^[A-Z]{6}\+/u, "")
|
||||
.replace(/["']/gu, "")
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim()
|
||||
.toLocaleLowerCase("en-US");
|
||||
}
|
||||
|
||||
function collectFonts(
|
||||
lines: PdfParagraphLayoutComparison["baseline"]["visualLines"],
|
||||
): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
lines.flatMap((line) => line.fontFamilies.map(normalizeFontFamily)),
|
||||
),
|
||||
].sort();
|
||||
}
|
||||
|
||||
async function cropRaster(
|
||||
raster: PdfPageRaster,
|
||||
bounds: PdfPointBounds,
|
||||
): Promise<PdfPageRaster> {
|
||||
const pixelsPerPoint = raster.dpi / 72;
|
||||
const left = Math.max(
|
||||
0,
|
||||
Math.floor((bounds.x - BLOCK_RASTER_PADDING_PT) * pixelsPerPoint),
|
||||
);
|
||||
const top = Math.max(
|
||||
0,
|
||||
Math.floor((bounds.y - BLOCK_RASTER_PADDING_PT) * pixelsPerPoint),
|
||||
);
|
||||
const right = Math.min(
|
||||
raster.widthPx,
|
||||
Math.ceil(
|
||||
(bounds.x + bounds.width + BLOCK_RASTER_PADDING_PT) * pixelsPerPoint,
|
||||
),
|
||||
);
|
||||
const bottom = Math.min(
|
||||
raster.heightPx,
|
||||
Math.ceil(
|
||||
(bounds.y + bounds.height + BLOCK_RASTER_PADDING_PT) * pixelsPerPoint,
|
||||
),
|
||||
);
|
||||
const widthPx = Math.max(1, right - left);
|
||||
const heightPx = Math.max(1, bottom - top);
|
||||
const source = await loadImage(Buffer.from(raster.png));
|
||||
const canvas = createCanvas(widthPx, heightPx);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, widthPx, heightPx);
|
||||
context.drawImage(
|
||||
source,
|
||||
left,
|
||||
top,
|
||||
widthPx,
|
||||
heightPx,
|
||||
0,
|
||||
0,
|
||||
widthPx,
|
||||
heightPx,
|
||||
);
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx,
|
||||
heightPx,
|
||||
dpi: raster.dpi,
|
||||
sha256: sha256(png),
|
||||
png,
|
||||
};
|
||||
}
|
||||
|
||||
async function padRaster(
|
||||
raster: PdfPageRaster,
|
||||
widthPx: number,
|
||||
heightPx: number,
|
||||
): Promise<PdfPageRaster> {
|
||||
if (raster.widthPx === widthPx && raster.heightPx === heightPx) {
|
||||
return raster;
|
||||
}
|
||||
const source = await loadImage(Buffer.from(raster.png));
|
||||
const canvas = createCanvas(widthPx, heightPx);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, widthPx, heightPx);
|
||||
context.drawImage(source, 0, 0);
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx,
|
||||
heightPx,
|
||||
dpi: raster.dpi,
|
||||
sha256: sha256(png),
|
||||
png,
|
||||
};
|
||||
}
|
||||
|
||||
function localRasterIssues(
|
||||
paragraphIndex: number,
|
||||
lineIndex: number,
|
||||
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
|
||||
thresholds: VisualDiffThresholds,
|
||||
): VisualDiffIssue[] {
|
||||
const failed =
|
||||
metrics.meanAbsoluteError > thresholds.maxMeanAbsoluteError ||
|
||||
metrics.changedPixelRatio > thresholds.maxChangedPixelRatio ||
|
||||
metrics.inkIou < thresholds.minInkIou ||
|
||||
metrics.edgeIou < thresholds.minEdgeIou;
|
||||
if (!failed) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
code: "SEMANTIC_BLOCK_RASTER_MISMATCH",
|
||||
severity: "failure",
|
||||
message: `第 ${paragraphIndex + 1} 个语义块的第 ${lineIndex + 1} 行局部视觉差异超限`,
|
||||
details: {
|
||||
paragraphIndex,
|
||||
lineIndex,
|
||||
meanAbsoluteError: metrics.meanAbsoluteError,
|
||||
changedPixelRatio: metrics.changedPixelRatio,
|
||||
inkIou: metrics.inkIou,
|
||||
edgeIou: metrics.edgeIou,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export async function comparePdfSemanticBlockVisuals(
|
||||
baseline: PdfDocumentSnapshot,
|
||||
candidate: PdfDocumentSnapshot,
|
||||
paragraphs: readonly PdfParagraphLayoutComparison[],
|
||||
thresholds: VisualDiffThresholds,
|
||||
): Promise<PdfSemanticBlockVisualComparison[]> {
|
||||
const comparisons: PdfSemanticBlockVisualComparison[] = [];
|
||||
for (const paragraph of paragraphs) {
|
||||
if (paragraph.expectation.section !== "body") {
|
||||
continue;
|
||||
}
|
||||
const issues: VisualDiffIssue[] = [];
|
||||
const baselineFonts = collectFonts(paragraph.baseline.visualLines);
|
||||
const candidateFonts = collectFonts(paragraph.candidate.visualLines);
|
||||
|
||||
const lines: PdfSemanticBlockVisualLineComparison[] = [];
|
||||
const comparableLineCount = Math.min(
|
||||
paragraph.baseline.visualLines.length,
|
||||
paragraph.candidate.visualLines.length,
|
||||
);
|
||||
if (
|
||||
!paragraph.baseline.matched ||
|
||||
!paragraph.candidate.matched ||
|
||||
comparableLineCount === 0
|
||||
) {
|
||||
issues.push({
|
||||
code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE",
|
||||
severity: "failure",
|
||||
message: `第 ${paragraph.expectation.index + 1} 个语义块缺少可比较的局部视觉观测`,
|
||||
details: { paragraphIndex: paragraph.expectation.index },
|
||||
});
|
||||
} else if (
|
||||
paragraph.baseline.visualLines.length ===
|
||||
paragraph.candidate.visualLines.length
|
||||
) {
|
||||
for (let lineIndex = 0; lineIndex < comparableLineCount; lineIndex += 1) {
|
||||
const baselineLine = paragraph.baseline.visualLines[lineIndex];
|
||||
const candidateLine = paragraph.candidate.visualLines[lineIndex];
|
||||
const baselinePage = baselineLine
|
||||
? baseline.pages[baselineLine.pageNumber - 1]
|
||||
: undefined;
|
||||
const candidatePage = candidateLine
|
||||
? candidate.pages[candidateLine.pageNumber - 1]
|
||||
: undefined;
|
||||
if (
|
||||
!baselineLine ||
|
||||
!candidateLine ||
|
||||
!baselinePage?.raster ||
|
||||
!candidatePage?.raster
|
||||
) {
|
||||
const unavailable: VisualDiffIssue = {
|
||||
code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE",
|
||||
severity: "failure",
|
||||
message: `第 ${paragraph.expectation.index + 1} 个语义块的第 ${lineIndex + 1} 行缺少栅格`,
|
||||
details: {
|
||||
paragraphIndex: paragraph.expectation.index,
|
||||
lineIndex,
|
||||
},
|
||||
};
|
||||
issues.push(unavailable);
|
||||
lines.push({
|
||||
lineIndex,
|
||||
baselinePageNumber: baselineLine?.pageNumber ?? -1,
|
||||
candidatePageNumber: candidateLine?.pageNumber ?? -1,
|
||||
issues: [unavailable],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const [baselineCrop, candidateCrop] = await Promise.all([
|
||||
cropRaster(baselinePage.raster, baselineLine.bounds),
|
||||
cropRaster(candidatePage.raster, candidateLine.bounds),
|
||||
]);
|
||||
const widthPx = Math.max(baselineCrop.widthPx, candidateCrop.widthPx);
|
||||
const heightPx = Math.max(
|
||||
baselineCrop.heightPx,
|
||||
candidateCrop.heightPx,
|
||||
);
|
||||
const [baselinePadded, candidatePadded] = await Promise.all([
|
||||
padRaster(baselineCrop, widthPx, heightPx),
|
||||
padRaster(candidateCrop, widthPx, heightPx),
|
||||
]);
|
||||
const result = await comparePageRasters(
|
||||
baselinePadded,
|
||||
candidatePadded,
|
||||
thresholds.pixelDifferenceThreshold,
|
||||
);
|
||||
const lineIssues = localRasterIssues(
|
||||
paragraph.expectation.index,
|
||||
lineIndex,
|
||||
result.metrics,
|
||||
thresholds,
|
||||
);
|
||||
issues.push(...lineIssues);
|
||||
lines.push({
|
||||
lineIndex,
|
||||
baselinePageNumber: baselineLine.pageNumber,
|
||||
candidatePageNumber: candidateLine.pageNumber,
|
||||
metrics: result.metrics,
|
||||
...(lineIssues.length > 0 ? { artifacts: result.artifacts } : {}),
|
||||
issues: lineIssues,
|
||||
});
|
||||
}
|
||||
}
|
||||
comparisons.push({
|
||||
expectation: paragraph.expectation,
|
||||
status: status(issues),
|
||||
baselineFontFamilies: baselineFonts,
|
||||
candidateFontFamilies: candidateFonts,
|
||||
lines,
|
||||
issues,
|
||||
});
|
||||
}
|
||||
return comparisons;
|
||||
}
|
||||
Reference in New Issue
Block a user