新增通用 CSS 到 OOXML 翻译修复,统一字体、字距、精确行距、段落、列表、表格、引用、代码块与行内代码连续性,不引入按主题 ID 分支。 新增 MdTP Mono 并统一 Serif、Sans、Mono 三字体包的 Chromium 与 DOCX 使用链;字体声明、嵌入部件和 Word/WPS 实际采用均进入硬门禁。 重建封面整页及正文语义块视觉差分,14 套主题、纵横两个方向、五组页边距共 140 个真实场景全部通过,阻断失败和诊断失败均为零。 源码服务、Docker Web API 与实际安装 Desktop 的 red-briefing 导出均包含 5 个字体部件;Word/WPS 原生渲染和逐页复核通过。修复 Docker 构建上下文与运行层复用软链接,并完善 v0.6.1 版本、发行说明和发布归集。 验证:npm test(116 个文件、616 项测试)、npm run typecheck、npm run build、git diff --check 全部通过。Desktop 安装器与 ZIP、Docker v0.6.1 镜像已生成;Windows 产物仍为未签名内部发行。
561 lines
16 KiB
TypeScript
561 lines
16 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
|
|
import { createCanvas, loadImage } from "@napi-rs/canvas";
|
|
|
|
import type {
|
|
PdfPageRaster,
|
|
ComparePageRasterOptions,
|
|
PdfRasterDiffArtifacts,
|
|
PdfRasterDiffMetrics,
|
|
PdfRasterDiffResult,
|
|
} from "./types.js";
|
|
|
|
interface DecodedRaster {
|
|
width: number;
|
|
height: number;
|
|
rgba: Uint8ClampedArray;
|
|
}
|
|
|
|
interface RgbColor {
|
|
red: number;
|
|
green: number;
|
|
blue: number;
|
|
}
|
|
|
|
const RASTER_MASK_BORDER_PX = 2;
|
|
|
|
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, width, height);
|
|
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 colorDistance(left: RgbColor, right: RgbColor): number {
|
|
return Math.sqrt(
|
|
((left.red - right.red) ** 2 +
|
|
(left.green - right.green) ** 2 +
|
|
(left.blue - right.blue) ** 2) /
|
|
3,
|
|
);
|
|
}
|
|
|
|
function rasterBackgroundColor(
|
|
rgba: Uint8ClampedArray,
|
|
_width: number,
|
|
_height: number,
|
|
): RgbColor {
|
|
const buckets = new Map<
|
|
number,
|
|
{ count: number; red: number; green: number; blue: number }
|
|
>();
|
|
let dominant:
|
|
| { count: number; red: number; green: number; blue: number }
|
|
| undefined;
|
|
for (let offset = 0; offset < rgba.length; offset += 4) {
|
|
const red = rgba[offset] ?? 255;
|
|
const green = rgba[offset + 1] ?? 255;
|
|
const blue = rgba[offset + 2] ?? 255;
|
|
const bucketKey = (red >> 3) << 10 | (green >> 3) << 5 | (blue >> 3);
|
|
const bucket = buckets.get(bucketKey) ?? {
|
|
count: 0,
|
|
red: 0,
|
|
green: 0,
|
|
blue: 0,
|
|
};
|
|
bucket.count += 1;
|
|
bucket.red += red;
|
|
bucket.green += green;
|
|
bucket.blue += blue;
|
|
buckets.set(bucketKey, bucket);
|
|
if (!dominant || bucket.count > dominant.count) {
|
|
dominant = bucket;
|
|
}
|
|
}
|
|
return !dominant
|
|
? { red: 255, green: 255, blue: 255 }
|
|
: {
|
|
red: dominant.red / dominant.count,
|
|
green: dominant.green / dominant.count,
|
|
blue: dominant.blue / dominant.count,
|
|
};
|
|
}
|
|
|
|
function rasterForegroundColor(
|
|
decoded: DecodedRaster,
|
|
): RgbColor {
|
|
const background = rasterBackgroundColor(
|
|
decoded.rgba,
|
|
decoded.width,
|
|
decoded.height,
|
|
);
|
|
const buckets = new Map<
|
|
number,
|
|
{ count: number; red: number; green: number; blue: number }
|
|
>();
|
|
for (let offset = 0; offset < decoded.rgba.length; offset += 4) {
|
|
const color = {
|
|
red: decoded.rgba[offset] ?? 255,
|
|
green: decoded.rgba[offset + 1] ?? 255,
|
|
blue: decoded.rgba[offset + 2] ?? 255,
|
|
};
|
|
const distance = colorDistance(color, background);
|
|
if (distance < 48) {
|
|
continue;
|
|
}
|
|
const bucketKey = (color.red >> 3) << 10 |
|
|
(color.green >> 3) << 5 |
|
|
(color.blue >> 3);
|
|
const bucket = buckets.get(bucketKey) ?? {
|
|
count: 0,
|
|
red: 0,
|
|
green: 0,
|
|
blue: 0,
|
|
};
|
|
bucket.count += 1;
|
|
bucket.red += color.red;
|
|
bucket.green += color.green;
|
|
bucket.blue += color.blue;
|
|
buckets.set(bucketKey, bucket);
|
|
}
|
|
let dominant:
|
|
| { count: number; red: number; green: number; blue: number }
|
|
| undefined;
|
|
let dominantDistance = -1;
|
|
for (const bucket of buckets.values()) {
|
|
const averageColor = {
|
|
red: bucket.red / bucket.count,
|
|
green: bucket.green / bucket.count,
|
|
blue: bucket.blue / bucket.count,
|
|
};
|
|
const distance = colorDistance(averageColor, background);
|
|
if (
|
|
distance > dominantDistance + 1 ||
|
|
(Math.abs(distance - dominantDistance) <= 1 &&
|
|
bucket.count > (dominant?.count ?? 0))
|
|
) {
|
|
dominant = bucket;
|
|
dominantDistance = distance;
|
|
}
|
|
}
|
|
return !dominant
|
|
? background
|
|
: {
|
|
red: dominant.red / dominant.count,
|
|
green: dominant.green / dominant.count,
|
|
blue: dominant.blue / dominant.count,
|
|
};
|
|
}
|
|
|
|
function clearMaskBorder(
|
|
mask: Uint8Array,
|
|
width: number,
|
|
height: number,
|
|
borderPx = RASTER_MASK_BORDER_PX,
|
|
) {
|
|
for (let y = 0; y < height; y += 1) {
|
|
for (let x = 0; x < width; x += 1) {
|
|
if (
|
|
x < borderPx ||
|
|
y < borderPx ||
|
|
x >= width - borderPx ||
|
|
y >= height - borderPx
|
|
) {
|
|
mask[y * width + x] = 0;
|
|
}
|
|
}
|
|
}
|
|
return mask;
|
|
}
|
|
|
|
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 dilateBinaryMask(
|
|
input: Uint8Array,
|
|
width: number,
|
|
height: number,
|
|
radius: number,
|
|
): Uint8Array {
|
|
if (radius === 0) {
|
|
return input;
|
|
}
|
|
const horizontal = new Uint8Array(input.length);
|
|
const output = new Uint8Array(input.length);
|
|
for (let y = 0; y < height; y += 1) {
|
|
const rowOffset = y * width;
|
|
for (let x = 0; x < width; x += 1) {
|
|
const start = Math.max(0, x - radius);
|
|
const end = Math.min(width - 1, x + radius);
|
|
for (let sampleX = start; sampleX <= end; sampleX += 1) {
|
|
if (input[rowOffset + sampleX] === 1) {
|
|
horizontal[rowOffset + x] = 1;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for (let y = 0; y < height; y += 1) {
|
|
for (let x = 0; x < width; x += 1) {
|
|
const start = Math.max(0, y - radius);
|
|
const end = Math.min(height - 1, y + radius);
|
|
for (let sampleY = start; sampleY <= end; sampleY += 1) {
|
|
if (horizontal[sampleY * width + x] === 1) {
|
|
output[y * width + x] = 1;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return output;
|
|
}
|
|
|
|
function calculateSpatiallyTolerantIou(
|
|
left: Uint8Array,
|
|
right: Uint8Array,
|
|
width: number,
|
|
height: number,
|
|
radius: number,
|
|
): number {
|
|
if (radius === 0) {
|
|
return calculateBinaryIou(left, right);
|
|
}
|
|
const dilatedLeft = dilateBinaryMask(left, width, height, radius);
|
|
const dilatedRight = dilateBinaryMask(right, width, height, radius);
|
|
let leftCount = 0;
|
|
let rightCount = 0;
|
|
let matchedLeft = 0;
|
|
let matchedRight = 0;
|
|
for (let index = 0; index < left.length; index += 1) {
|
|
if (left[index] === 1) {
|
|
leftCount += 1;
|
|
if (dilatedRight[index] === 1) {
|
|
matchedLeft += 1;
|
|
}
|
|
}
|
|
if (right[index] === 1) {
|
|
rightCount += 1;
|
|
if (dilatedLeft[index] === 1) {
|
|
matchedRight += 1;
|
|
}
|
|
}
|
|
}
|
|
const matchedIntersection = (matchedLeft + matchedRight) / 2;
|
|
const union = leftCount + rightCount - matchedIntersection;
|
|
return union === 0 ? 1 : matchedIntersection / 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(
|
|
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);
|
|
const baselinePng = encodeGrayscalePng(baselineGray, width, height);
|
|
const candidatePng = encodeGrayscalePng(candidateGray, width, height);
|
|
return {
|
|
baselinePng,
|
|
candidatePng,
|
|
overlayPng,
|
|
heatmapPng,
|
|
baselineSha256: sha256(baselinePng),
|
|
candidateSha256: sha256(candidatePng),
|
|
overlaySha256: sha256(overlayPng),
|
|
heatmapSha256: sha256(heatmapPng),
|
|
};
|
|
}
|
|
|
|
function encodeGrayscalePng(
|
|
grayscale: Uint8Array,
|
|
width: number,
|
|
height: number,
|
|
): Uint8Array {
|
|
const rgba = new Uint8ClampedArray(width * height * 4);
|
|
for (let index = 0; index < grayscale.length; index += 1) {
|
|
const value = grayscale[index] ?? 255;
|
|
const offset = index * 4;
|
|
rgba[offset] = value;
|
|
rgba[offset + 1] = value;
|
|
rgba[offset + 2] = value;
|
|
rgba[offset + 3] = 255;
|
|
}
|
|
return encodeRgbaPng(rgba, width, height);
|
|
}
|
|
|
|
export async function comparePageRasters(
|
|
baseline: PdfPageRaster,
|
|
candidate: PdfPageRaster,
|
|
options: number | ComparePageRasterOptions = {},
|
|
): Promise<PdfRasterDiffResult> {
|
|
const resolvedOptions =
|
|
typeof options === "number"
|
|
? { pixelDifferenceThreshold: options }
|
|
: options;
|
|
const pixelDifferenceThreshold =
|
|
resolvedOptions.pixelDifferenceThreshold ?? 8;
|
|
const spatialTolerancePx = resolvedOptions.spatialTolerancePx ?? 4;
|
|
if (
|
|
!Number.isInteger(pixelDifferenceThreshold) ||
|
|
pixelDifferenceThreshold < 0 ||
|
|
pixelDifferenceThreshold > 255
|
|
) {
|
|
throw new Error("像素变化阈值必须是 0 到 255 之间的整数");
|
|
}
|
|
if (
|
|
!Number.isInteger(spatialTolerancePx) ||
|
|
spatialTolerancePx < 0 ||
|
|
spatialTolerancePx > 8
|
|
) {
|
|
throw new Error("空间容差必须是 0 到 8 之间的整数像素");
|
|
}
|
|
const width =
|
|
resolvedOptions.targetWidthPx ??
|
|
Math.max(baseline.widthPx, candidate.widthPx);
|
|
const height =
|
|
resolvedOptions.targetHeightPx ??
|
|
Math.max(baseline.heightPx, candidate.heightPx);
|
|
if (!Number.isSafeInteger(width) || width <= 0 ||
|
|
!Number.isSafeInteger(height) || height <= 0) {
|
|
throw new Error("栅格比较尺寸必须是正整数");
|
|
}
|
|
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);
|
|
const baselineBackground = rasterBackgroundColor(
|
|
baselineDecoded.rgba,
|
|
width,
|
|
height,
|
|
);
|
|
const candidateBackground = rasterBackgroundColor(
|
|
candidateDecoded.rgba,
|
|
width,
|
|
height,
|
|
);
|
|
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;
|
|
const baselineColor = {
|
|
red: baselineDecoded.rgba[offset] ?? 255,
|
|
green: baselineDecoded.rgba[offset + 1] ?? 255,
|
|
blue: baselineDecoded.rgba[offset + 2] ?? 255,
|
|
};
|
|
const candidateColor = {
|
|
red: candidateDecoded.rgba[offset] ?? 255,
|
|
green: candidateDecoded.rgba[offset + 1] ?? 255,
|
|
blue: candidateDecoded.rgba[offset + 2] ?? 255,
|
|
};
|
|
if (colorDistance(baselineColor, baselineBackground) >= 16) {
|
|
baselineInk[pixelIndex] = 1;
|
|
}
|
|
if (colorDistance(candidateColor, candidateBackground) >= 16) {
|
|
candidateInk[pixelIndex] = 1;
|
|
}
|
|
}
|
|
|
|
clearMaskBorder(baselineInk, width, height);
|
|
clearMaskBorder(candidateInk, width, height);
|
|
const baselineEdges = clearMaskBorder(
|
|
createEdgeMask(baselineGray, width, height, 40),
|
|
width,
|
|
height,
|
|
);
|
|
const candidateEdges = clearMaskBorder(
|
|
createEdgeMask(candidateGray, width, height, 40),
|
|
width,
|
|
height,
|
|
);
|
|
const foregroundColorDelta = colorDistance(
|
|
rasterForegroundColor(baselineDecoded),
|
|
rasterForegroundColor(candidateDecoded),
|
|
);
|
|
const backgroundColorDelta = colorDistance(
|
|
baselineBackground,
|
|
candidateBackground,
|
|
);
|
|
const metrics: PdfRasterDiffMetrics = {
|
|
baselineWidthPx: baseline.widthPx,
|
|
baselineHeightPx: baseline.heightPx,
|
|
candidateWidthPx: candidate.widthPx,
|
|
candidateHeightPx: candidate.heightPx,
|
|
comparedWidthPx: width,
|
|
comparedHeightPx: height,
|
|
dimensionsMatch:
|
|
baseline.widthPx === candidate.widthPx &&
|
|
baseline.heightPx === candidate.heightPx,
|
|
geometryNormalized: resolvedOptions.geometryNormalized ?? false,
|
|
spatialTolerancePx,
|
|
meanAbsoluteError: absoluteError / (pixelCount * 3),
|
|
changedPixelRatio: changedPixels / pixelCount,
|
|
inkIou: calculateSpatiallyTolerantIou(
|
|
baselineInk,
|
|
candidateInk,
|
|
width,
|
|
height,
|
|
spatialTolerancePx,
|
|
),
|
|
edgeIou: calculateSpatiallyTolerantIou(
|
|
baselineEdges,
|
|
candidateEdges,
|
|
width,
|
|
height,
|
|
spatialTolerancePx,
|
|
),
|
|
backgroundColorDelta,
|
|
foregroundColorDelta,
|
|
};
|
|
return {
|
|
metrics,
|
|
artifacts: createArtifacts(
|
|
baselineGray,
|
|
candidateGray,
|
|
pixelDifference,
|
|
width,
|
|
height,
|
|
),
|
|
};
|
|
}
|