feat: 完成 DOCX R4 元素级视觉门禁

建立 Chromium、Word 与 WPS 的可编辑内容、段落几何、页眉页脚、封面、媒体和逐页视觉比较链路。

支持封面独立分节、正文页码重启、主题页边距与横向纸张,并将自然分页差异降为诊断项。

修正代码块内边距和折叠表格单元格边框映射,14 套主题生产矩阵与六组布局视觉矩阵通过。
This commit is contained in:
SkyJourney
2026-08-02 03:27:11 +08:00
parent f9f5fccfc9
commit 58f87cc19f
100 changed files with 10409 additions and 386 deletions
+47 -12
View File
@@ -4,6 +4,7 @@ import { createCanvas, loadImage } from "@napi-rs/canvas";
import type {
PdfPageRaster,
ComparePageRasterOptions,
PdfRasterDiffArtifacts,
PdfRasterDiffMetrics,
PdfRasterDiffResult,
@@ -29,7 +30,7 @@ async function decodeRaster(
const context = canvas.getContext("2d");
context.fillStyle = "#ffffff";
context.fillRect(0, 0, width, height);
context.drawImage(image, 0, 0);
context.drawImage(image, 0, 0, width, height);
return {
width,
height,
@@ -107,8 +108,6 @@ function encodeRgbaPng(
}
function createArtifacts(
baseline: PdfPageRaster,
candidate: PdfPageRaster,
baselineGray: Uint8Array,
candidateGray: Uint8Array,
pixelDifference: Uint8Array,
@@ -142,23 +141,48 @@ function createArtifacts(
}
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: baseline.png,
candidatePng: candidate.png,
baselinePng,
candidatePng,
overlayPng,
heatmapPng,
baselineSha256: baseline.sha256,
candidateSha256: candidate.sha256,
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,
pixelDifferenceThreshold = 8,
options: number | ComparePageRasterOptions = {},
): Promise<PdfRasterDiffResult> {
const resolvedOptions =
typeof options === "number"
? { pixelDifferenceThreshold: options }
: options;
const pixelDifferenceThreshold =
resolvedOptions.pixelDifferenceThreshold ?? 8;
if (
!Number.isInteger(pixelDifferenceThreshold) ||
pixelDifferenceThreshold < 0 ||
@@ -166,8 +190,16 @@ export async function comparePageRasters(
) {
throw new Error("像素变化阈值必须是 0 到 255 之间的整数");
}
const width = Math.max(baseline.widthPx, candidate.widthPx);
const height = Math.max(baseline.heightPx, candidate.heightPx);
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),
@@ -230,11 +262,16 @@ export async function comparePageRasters(
40,
);
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,
meanAbsoluteError: absoluteError / (pixelCount * 3),
changedPixelRatio: changedPixels / pixelCount,
inkIou: calculateBinaryIou(baselineInk, candidateInk),
@@ -243,8 +280,6 @@ export async function comparePageRasters(
return {
metrics,
artifacts: createArtifacts(
baseline,
candidate,
baselineGray,
candidateGray,
pixelDifference,