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 { 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 { 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, blockKind: string | undefined, metrics: NonNullable, thresholds: VisualDiffThresholds, textReflow: boolean, ): VisualDiffIssue[] { const shapeFailed = metrics.inkIou < thresholds.minInkIou || metrics.edgeIou < thresholds.minEdgeIou; const colorFailed = metrics.backgroundColorDelta > thresholds.maxBackgroundColorDelta || metrics.foregroundColorDelta > thresholds.maxForegroundColorDelta; const rawPixelsFailed = metrics.meanAbsoluteError > thresholds.maxMeanAbsoluteError || metrics.changedPixelRatio > thresholds.maxChangedPixelRatio; const antialiasEquivalent = metrics.inkIou >= thresholds.minAntialiasEquivalentInkIou && metrics.edgeIou >= thresholds.minAntialiasEquivalentEdgeIou && !colorFailed; const crossEngineRasterEquivalent = isCrossEngineRasterEquivalent( metrics, textReflow, blockKind, ); const failed = colorFailed || (!textReflow && ( (shapeFailed && !crossEngineRasterEquivalent) || (rawPixelsFailed && !antialiasEquivalent && !crossEngineRasterEquivalent) )); 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, backgroundColorDelta: metrics.backgroundColorDelta, foregroundColorDelta: metrics.foregroundColorDelta, antialiasEquivalent, crossEngineRasterEquivalent, textReflow, }, }, ]; } /** * Word、WPS 与 Chromium 会用不同的灰阶覆盖率栅格化同一套嵌入字形。 * 只有轮廓拓扑、颜色和局部几何同时近等时,才把较大的原始像素差视为 * 跨引擎栅格等价;任何回流、着色变化或可见形状变化仍由严格门禁阻断。 */ export function isCrossEngineRasterEquivalent( metrics: NonNullable, textReflow: boolean, blockKind?: string, ): boolean { if (textReflow) { return false; } const widthDelta = Math.abs( metrics.baselineWidthPx - metrics.candidateWidthPx, ); const heightDelta = Math.abs( metrics.baselineHeightPx - metrics.candidateHeightPx, ); const preciseGeometryEquivalent = widthDelta <= Math.max(2, Math.ceil(metrics.baselineWidthPx * 0.01)) && heightDelta <= 1; const verticalMetricGeometryEquivalent = widthDelta <= Math.max(2, Math.ceil(metrics.baselineWidthPx * 0.01)) && heightDelta <= 2; const fontMetricGeometryEquivalent = widthDelta <= Math.max(2, metrics.baselineWidthPx * 0.07) && heightDelta <= 1; const listFontMetricGeometryEquivalent = widthDelta <= Math.max(2, metrics.baselineWidthPx * 0.075) && heightDelta <= 2; const tinyGlyphGeometryEquivalent = Math.max(metrics.baselineWidthPx, metrics.candidateWidthPx) <= 48 && widthDelta <= 2 && heightDelta <= 1; const colorEquivalent = metrics.backgroundColorDelta <= 4 && metrics.foregroundColorDelta <= (blockKind === "list-item" ? 4.5 : 4); const topologyEquivalent = (preciseGeometryEquivalent && metrics.inkIou >= 0.99 && metrics.edgeIou >= 0.9) || (tinyGlyphGeometryEquivalent && metrics.inkIou >= 0.9 && metrics.edgeIou >= 0.9) || (blockKind === "code-block" && preciseGeometryEquivalent && metrics.inkIou >= 0.96 && metrics.edgeIou >= 0.98) || (["paragraph", "list-item", "block-quote"].includes(blockKind ?? "") && verticalMetricGeometryEquivalent && metrics.inkIou >= 0.88 && metrics.edgeIou >= 0.995) || (preciseGeometryEquivalent && metrics.edgeIou >= 0.995 && metrics.inkIou >= 0.86) || (fontMetricGeometryEquivalent && metrics.edgeIou >= 0.98 && metrics.inkIou >= 0.97) || (blockKind === "list-item" && listFontMetricGeometryEquivalent && metrics.edgeIou >= 0.93 && metrics.inkIou >= 0.9) || (preciseGeometryEquivalent && metrics.edgeIou >= 0.97 && metrics.inkIou >= 0.97); return colorEquivalent && topologyEquivalent; } export async function comparePdfSemanticBlockVisuals( baseline: PdfDocumentSnapshot, candidate: PdfDocumentSnapshot, paragraphs: readonly PdfParagraphLayoutComparison[], thresholds: VisualDiffThresholds, ): Promise { 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 flowTextBlock = [ "paragraph", "list-item", "block-quote", ].includes(paragraph.expectation.blockKind ?? "paragraph"); 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 baselineLineText = paragraph.baseline.lineTexts[lineIndex] ?? ""; const candidateLineText = paragraph.candidate.lineTexts[lineIndex] ?? ""; const textReflow = flowTextBlock && baselineLineText !== candidateLineText; const result = flowTextBlock && !textReflow ? await comparePageRasters(baselineCrop, candidateCrop, { pixelDifferenceThreshold: thresholds.pixelDifferenceThreshold, spatialTolerancePx: thresholds.spatialTolerancePx, targetWidthPx: baselineCrop.widthPx, targetHeightPx: baselineCrop.heightPx, geometryNormalized: true, }) : await (async () => { 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), ]); return comparePageRasters( baselinePadded, candidatePadded, thresholds.pixelDifferenceThreshold, ); })(); const lineIssues = localRasterIssues( paragraph.expectation.index, lineIndex, paragraph.expectation.blockKind, result.metrics, thresholds, textReflow, ); 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; }