feat: 完成 DOCX 视觉门禁与字体兼容映射
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { createCanvas, loadImage } from "@napi-rs/canvas";
|
||||
|
||||
import type {
|
||||
PdfPageRaster,
|
||||
PdfRasterDiffArtifacts,
|
||||
PdfRasterDiffMetrics,
|
||||
PdfRasterDiffResult,
|
||||
} from "./types.js";
|
||||
|
||||
interface DecodedRaster {
|
||||
width: number;
|
||||
height: number;
|
||||
rgba: Uint8ClampedArray;
|
||||
}
|
||||
|
||||
function sha256(bytes: Uint8Array): string {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
async function decodeRaster(
|
||||
raster: PdfPageRaster,
|
||||
width: number,
|
||||
height: number,
|
||||
): Promise<DecodedRaster> {
|
||||
const image = await loadImage(Buffer.from(raster.png));
|
||||
const canvas = createCanvas(width, height);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, width, height);
|
||||
context.drawImage(image, 0, 0);
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
rgba: context.getImageData(0, 0, width, height).data,
|
||||
};
|
||||
}
|
||||
|
||||
function luminance(
|
||||
rgba: Uint8ClampedArray,
|
||||
offset: number,
|
||||
): number {
|
||||
const red = rgba[offset] ?? 255;
|
||||
const green = rgba[offset + 1] ?? 255;
|
||||
const blue = rgba[offset + 2] ?? 255;
|
||||
return (77 * red + 150 * green + 29 * blue) >> 8;
|
||||
}
|
||||
|
||||
function calculateBinaryIou(
|
||||
left: Uint8Array,
|
||||
right: Uint8Array,
|
||||
): number {
|
||||
let intersection = 0;
|
||||
let union = 0;
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
const leftValue = left[index] === 1;
|
||||
const rightValue = right[index] === 1;
|
||||
if (leftValue || rightValue) {
|
||||
union += 1;
|
||||
if (leftValue && rightValue) {
|
||||
intersection += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return union === 0 ? 1 : intersection / union;
|
||||
}
|
||||
|
||||
function createEdgeMask(
|
||||
grayscale: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
threshold: number,
|
||||
): Uint8Array {
|
||||
const edges = new Uint8Array(width * height);
|
||||
for (let y = 1; y < height - 1; y += 1) {
|
||||
const rowOffset = y * width;
|
||||
for (let x = 1; x < width - 1; x += 1) {
|
||||
const index = rowOffset + x;
|
||||
const horizontal = Math.abs(
|
||||
(grayscale[index + 1] ?? 255) -
|
||||
(grayscale[index - 1] ?? 255),
|
||||
);
|
||||
const vertical = Math.abs(
|
||||
(grayscale[index + width] ?? 255) -
|
||||
(grayscale[index - width] ?? 255),
|
||||
);
|
||||
if (horizontal + vertical >= threshold) {
|
||||
edges[index] = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
function encodeRgbaPng(
|
||||
rgba: Uint8ClampedArray,
|
||||
width: number,
|
||||
height: number,
|
||||
): Uint8Array {
|
||||
const canvas = createCanvas(width, height);
|
||||
const context = canvas.getContext("2d");
|
||||
const imageData = context.createImageData(width, height);
|
||||
imageData.data.set(rgba);
|
||||
context.putImageData(imageData, 0, 0);
|
||||
return Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
}
|
||||
|
||||
function createArtifacts(
|
||||
baseline: PdfPageRaster,
|
||||
candidate: PdfPageRaster,
|
||||
baselineGray: Uint8Array,
|
||||
candidateGray: Uint8Array,
|
||||
pixelDifference: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
): PdfRasterDiffArtifacts {
|
||||
const overlay = new Uint8ClampedArray(width * height * 4);
|
||||
const heatmap = new Uint8ClampedArray(width * height * 4);
|
||||
for (let pixelIndex = 0; pixelIndex < width * height; pixelIndex += 1) {
|
||||
const outputOffset = pixelIndex * 4;
|
||||
const baselineValue = baselineGray[pixelIndex] ?? 255;
|
||||
const candidateValue = candidateGray[pixelIndex] ?? 255;
|
||||
const difference = pixelDifference[pixelIndex] ?? 0;
|
||||
|
||||
overlay[outputOffset] = baselineValue;
|
||||
overlay[outputOffset + 1] = candidateValue;
|
||||
overlay[outputOffset + 2] = candidateValue;
|
||||
overlay[outputOffset + 3] = 255;
|
||||
|
||||
if (difference === 0) {
|
||||
heatmap[outputOffset] = 255;
|
||||
heatmap[outputOffset + 1] = 255;
|
||||
heatmap[outputOffset + 2] = 255;
|
||||
} else {
|
||||
const intensity = Math.min(255, difference * 4);
|
||||
heatmap[outputOffset] = 255;
|
||||
heatmap[outputOffset + 1] = 255 - intensity;
|
||||
heatmap[outputOffset + 2] = Math.max(0, 160 - intensity);
|
||||
}
|
||||
heatmap[outputOffset + 3] = 255;
|
||||
}
|
||||
const overlayPng = encodeRgbaPng(overlay, width, height);
|
||||
const heatmapPng = encodeRgbaPng(heatmap, width, height);
|
||||
return {
|
||||
baselinePng: baseline.png,
|
||||
candidatePng: candidate.png,
|
||||
overlayPng,
|
||||
heatmapPng,
|
||||
baselineSha256: baseline.sha256,
|
||||
candidateSha256: candidate.sha256,
|
||||
overlaySha256: sha256(overlayPng),
|
||||
heatmapSha256: sha256(heatmapPng),
|
||||
};
|
||||
}
|
||||
|
||||
export async function comparePageRasters(
|
||||
baseline: PdfPageRaster,
|
||||
candidate: PdfPageRaster,
|
||||
pixelDifferenceThreshold = 8,
|
||||
): Promise<PdfRasterDiffResult> {
|
||||
if (
|
||||
!Number.isInteger(pixelDifferenceThreshold) ||
|
||||
pixelDifferenceThreshold < 0 ||
|
||||
pixelDifferenceThreshold > 255
|
||||
) {
|
||||
throw new Error("像素变化阈值必须是 0 到 255 之间的整数");
|
||||
}
|
||||
const width = Math.max(baseline.widthPx, candidate.widthPx);
|
||||
const height = Math.max(baseline.heightPx, candidate.heightPx);
|
||||
const [baselineDecoded, candidateDecoded] = await Promise.all([
|
||||
decodeRaster(baseline, width, height),
|
||||
decodeRaster(candidate, width, height),
|
||||
]);
|
||||
const pixelCount = width * height;
|
||||
const baselineGray = new Uint8Array(pixelCount);
|
||||
const candidateGray = new Uint8Array(pixelCount);
|
||||
const pixelDifference = new Uint8Array(pixelCount);
|
||||
const baselineInk = new Uint8Array(pixelCount);
|
||||
const candidateInk = new Uint8Array(pixelCount);
|
||||
let absoluteError = 0;
|
||||
let changedPixels = 0;
|
||||
|
||||
for (let pixelIndex = 0; pixelIndex < pixelCount; pixelIndex += 1) {
|
||||
const offset = pixelIndex * 4;
|
||||
const redDifference = Math.abs(
|
||||
(baselineDecoded.rgba[offset] ?? 255) -
|
||||
(candidateDecoded.rgba[offset] ?? 255),
|
||||
);
|
||||
const greenDifference = Math.abs(
|
||||
(baselineDecoded.rgba[offset + 1] ?? 255) -
|
||||
(candidateDecoded.rgba[offset + 1] ?? 255),
|
||||
);
|
||||
const blueDifference = Math.abs(
|
||||
(baselineDecoded.rgba[offset + 2] ?? 255) -
|
||||
(candidateDecoded.rgba[offset + 2] ?? 255),
|
||||
);
|
||||
absoluteError += redDifference + greenDifference + blueDifference;
|
||||
const maximumDifference = Math.max(
|
||||
redDifference,
|
||||
greenDifference,
|
||||
blueDifference,
|
||||
);
|
||||
pixelDifference[pixelIndex] = maximumDifference;
|
||||
if (maximumDifference > pixelDifferenceThreshold) {
|
||||
changedPixels += 1;
|
||||
}
|
||||
const baselineValue = luminance(baselineDecoded.rgba, offset);
|
||||
const candidateValue = luminance(candidateDecoded.rgba, offset);
|
||||
baselineGray[pixelIndex] = baselineValue;
|
||||
candidateGray[pixelIndex] = candidateValue;
|
||||
if (baselineValue < 245) {
|
||||
baselineInk[pixelIndex] = 1;
|
||||
}
|
||||
if (candidateValue < 245) {
|
||||
candidateInk[pixelIndex] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const baselineEdges = createEdgeMask(
|
||||
baselineGray,
|
||||
width,
|
||||
height,
|
||||
40,
|
||||
);
|
||||
const candidateEdges = createEdgeMask(
|
||||
candidateGray,
|
||||
width,
|
||||
height,
|
||||
40,
|
||||
);
|
||||
const metrics: PdfRasterDiffMetrics = {
|
||||
comparedWidthPx: width,
|
||||
comparedHeightPx: height,
|
||||
dimensionsMatch:
|
||||
baseline.widthPx === candidate.widthPx &&
|
||||
baseline.heightPx === candidate.heightPx,
|
||||
meanAbsoluteError: absoluteError / (pixelCount * 3),
|
||||
changedPixelRatio: changedPixels / pixelCount,
|
||||
inkIou: calculateBinaryIou(baselineInk, candidateInk),
|
||||
edgeIou: calculateBinaryIou(baselineEdges, candidateEdges),
|
||||
};
|
||||
return {
|
||||
metrics,
|
||||
artifacts: createArtifacts(
|
||||
baseline,
|
||||
candidate,
|
||||
baselineGray,
|
||||
candidateGray,
|
||||
pixelDifference,
|
||||
width,
|
||||
height,
|
||||
),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user