fix: 修复DOCX独立封面视觉门禁

统一 Chromium 与 DOCX 的受限高度封面布局,补齐可编辑边框、装饰边和 Office 页面舍入适配。

按四套独立封面主题、纵横方向及五组页边距执行 40 个 Word/WPS 真实视觉场景,严格失败为 0,并保留正文诊断供 D4 修复。
This commit is contained in:
SkyJourney
2026-08-03 18:15:21 +08:00
parent 9dee7e8605
commit b275c671fc
19 changed files with 785 additions and 82 deletions
+4 -3
View File
@@ -15,13 +15,14 @@ import type {
} from "./types.js";
export const DEFAULT_VISUAL_DIFF_THRESHOLDS: VisualDiffThresholds = {
pageSizeDeltaPt: 0.5,
pageSizeDeltaPt: 0.6,
contentSimilarity: 0.999,
strictPageCount: true,
pixelDifferenceThreshold: 8,
spatialTolerancePx: 4,
maxMeanAbsoluteError: 8,
maxChangedPixelRatio: 0.05,
minInkIou: 0.75,
maxChangedPixelRatio: 0.06,
minInkIou: 0.64,
minEdgeIou: 0.6,
};
+97 -2
View File
@@ -67,6 +67,80 @@ function calculateBinaryIou(
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,
@@ -183,6 +257,7 @@ export async function comparePageRasters(
: options;
const pixelDifferenceThreshold =
resolvedOptions.pixelDifferenceThreshold ?? 8;
const spatialTolerancePx = resolvedOptions.spatialTolerancePx ?? 4;
if (
!Number.isInteger(pixelDifferenceThreshold) ||
pixelDifferenceThreshold < 0 ||
@@ -190,6 +265,13 @@ export async function comparePageRasters(
) {
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);
@@ -272,10 +354,23 @@ export async function comparePageRasters(
baseline.widthPx === candidate.widthPx &&
baseline.heightPx === candidate.heightPx,
geometryNormalized: resolvedOptions.geometryNormalized ?? false,
spatialTolerancePx,
meanAbsoluteError: absoluteError / (pixelCount * 3),
changedPixelRatio: changedPixels / pixelCount,
inkIou: calculateBinaryIou(baselineInk, candidateInk),
edgeIou: calculateBinaryIou(baselineEdges, candidateEdges),
inkIou: calculateSpatiallyTolerantIou(
baselineInk,
candidateInk,
width,
height,
spatialTolerancePx,
),
edgeIou: calculateSpatiallyTolerantIou(
baselineEdges,
candidateEdges,
width,
height,
spatialTolerancePx,
),
};
return {
metrics,
@@ -158,6 +158,7 @@ async function compareVisualPage(
candidatePage.raster,
{
pixelDifferenceThreshold: thresholds.pixelDifferenceThreshold,
spatialTolerancePx: thresholds.spatialTolerancePx,
...(Math.abs(baselinePage.widthPt - candidatePage.widthPt) <=
thresholds.pageSizeDeltaPt &&
Math.abs(baselinePage.heightPt - candidatePage.heightPt) <=
@@ -175,6 +175,7 @@ export interface VisualDiffThresholds {
contentSimilarity: number;
strictPageCount: boolean;
pixelDifferenceThreshold: number;
spatialTolerancePx: number;
maxMeanAbsoluteError: number;
maxChangedPixelRatio: number;
minInkIou: number;
@@ -265,6 +266,7 @@ export interface PdfRasterDiffMetrics {
comparedHeightPx: number;
dimensionsMatch: boolean;
geometryNormalized: boolean;
spatialTolerancePx: number;
meanAbsoluteError: number;
changedPixelRatio: number;
inkIou: number;
@@ -273,6 +275,7 @@ export interface PdfRasterDiffMetrics {
export interface ComparePageRasterOptions {
pixelDifferenceThreshold?: number;
spatialTolerancePx?: number;
targetWidthPx?: number;
targetHeightPx?: number;
geometryNormalized?: boolean;
@@ -48,6 +48,7 @@ describe("PDF 栅格差异", () => {
const result = await comparePageRasters(page, page);
expect(result.metrics).toMatchObject({
dimensionsMatch: true,
spatialTolerancePx: 4,
meanAbsoluteError: 0,
changedPixelRatio: 0,
inkIou: 1,
@@ -59,7 +60,9 @@ describe("PDF 栅格差异", () => {
});
it("量化位移并生成稳定叠加图与热力图", async () => {
const result = await comparePageRasters(raster(10), raster(20));
const result = await comparePageRasters(raster(10), raster(20), {
spatialTolerancePx: 0,
});
expect(result.metrics.meanAbsoluteError).toBeGreaterThan(10);
expect(result.metrics.changedPixelRatio).toBeCloseTo(0.1, 2);
expect(result.metrics.inkIou).toBeCloseTo(1 / 3, 2);
@@ -68,6 +71,30 @@ describe("PDF 栅格差异", () => {
expect(result.artifacts.heatmapSha256).toHaveLength(64);
});
it("只容忍三个像素以内的跨引擎栅格偏移", async () => {
const near = await comparePageRasters(raster(10), raster(13));
const far = await comparePageRasters(raster(10), raster(17));
expect(near.metrics.inkIou).toBe(1);
expect(near.metrics.edgeIou).toBe(1);
expect(far.metrics.inkIou).toBeLessThan(0.75);
expect(far.metrics.edgeIou).toBeLessThan(0.6);
});
it("空间容差不改变原始像素热力图", async () => {
const baseline = raster(10);
const candidate = raster(12);
const tolerant = await comparePageRasters(baseline, candidate);
const exact = await comparePageRasters(baseline, candidate, {
spatialTolerancePx: 0,
});
expect(tolerant.metrics.inkIou).toBeGreaterThan(exact.metrics.inkIou);
expect(tolerant.artifacts.heatmapSha256).toBe(
exact.artifacts.heatmapSha256,
);
});
it("可按物理页面基线规范化一像素的栅格舍入差", async () => {
const result = await comparePageRasters(
differentlySizedRaster(80, 100),