feat: 完成 DOCX 视觉门禁与字体兼容映射

This commit is contained in:
SkyJourney
2026-07-31 20:13:19 +08:00
parent 10bc62cee4
commit e5da699ea3
32 changed files with 3015 additions and 17 deletions
+255
View File
@@ -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,
),
};
}