release: 发布 v0.6.2 DOCX 真实文档修复
新增能力:将 DOCX 发布验收拆分为四套独立 140,支持真实语料冻结、指纹复用、失败与基础设施错误独立统计,并为表格换行、全 JSON 围栏、代码连续性和长文档分页建立通用门禁。 问题修复:冻结 Paged.js 分片前的逻辑表格列轨并传递打印几何,统一 Markdown 表格换行、代码、段落与 OOXML 翻译;改进 PDF 文本流排序、语义块映射、颜色与栅格比较,消除窄字符重叠和跨行范围符号误报。 兼容与部署:版本统一为 0.6.2;正式 Docker 镜像内置固定 Chromium、Pandoc 3.9.0.2 和 Serif/Sans/Mono 字体;Desktop NSIS 与 ZIP 继续直接内置字体,无需系统字体安装。 验证结果:合成基线与长庆严格 280/280,M4N 140/140;健康数据残余误报 6/115(5.22%),均核查为重复表头自动对齐/取样误报且基础设施错误为 0。全项目测试、类型检查、生产构建和 git diff --check 通过;正式 Docker、NSIS、ZIP、离线镜像、部署包、清单及 SHA-256 均已生成并校验。
This commit is contained in:
@@ -33,12 +33,35 @@ async function decodeRaster(
|
||||
width: number,
|
||||
height: number,
|
||||
): Promise<DecodedRaster> {
|
||||
const image = await loadImage(Buffer.from(raster.png));
|
||||
if (
|
||||
raster.rgba?.length === raster.widthPx * raster.heightPx * 4 &&
|
||||
raster.widthPx === width &&
|
||||
raster.heightPx === height
|
||||
) {
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
rgba: raster.rgba,
|
||||
};
|
||||
}
|
||||
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);
|
||||
if (raster.rgba?.length === raster.widthPx * raster.heightPx * 4) {
|
||||
const sourceCanvas = createCanvas(raster.widthPx, raster.heightPx);
|
||||
const sourceContext = sourceCanvas.getContext("2d");
|
||||
const sourceData = sourceContext.createImageData(
|
||||
raster.widthPx,
|
||||
raster.heightPx,
|
||||
);
|
||||
sourceData.data.set(raster.rgba);
|
||||
sourceContext.putImageData(sourceData, 0, 0);
|
||||
context.drawImage(sourceCanvas, 0, 0, width, height);
|
||||
} else {
|
||||
const image = await loadImage(Buffer.from(raster.png));
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
}
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
@@ -65,11 +88,66 @@ function colorDistance(left: RgbColor, right: RgbColor): number {
|
||||
);
|
||||
}
|
||||
|
||||
function colorChroma(color: RgbColor): number {
|
||||
return Math.max(color.red, color.green, color.blue) -
|
||||
Math.min(color.red, color.green, color.blue);
|
||||
}
|
||||
|
||||
function colorLuminance(color: RgbColor): number {
|
||||
return (77 * color.red + 150 * color.green + 29 * color.blue) / 256;
|
||||
}
|
||||
|
||||
function rasterBackgroundColor(
|
||||
rgba: Uint8ClampedArray,
|
||||
_width: number,
|
||||
_height: number,
|
||||
width: number,
|
||||
height: number,
|
||||
): RgbColor {
|
||||
const red: number[] = [];
|
||||
const green: number[] = [];
|
||||
const blue: number[] = [];
|
||||
const append = (x: number, y: number) => {
|
||||
const offset = (y * width + x) * 4;
|
||||
red.push(rgba[offset] ?? 255);
|
||||
green.push(rgba[offset + 1] ?? 255);
|
||||
blue.push(rgba[offset + 2] ?? 255);
|
||||
};
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
append(x, 0);
|
||||
if (height > 1) {
|
||||
append(x, height - 1);
|
||||
}
|
||||
}
|
||||
for (let y = 1; y < height - 1; y += 1) {
|
||||
append(0, y);
|
||||
if (width > 1) {
|
||||
append(width - 1, y);
|
||||
}
|
||||
}
|
||||
const median = (values: number[]) => {
|
||||
if (values.length === 0) {
|
||||
return 255;
|
||||
}
|
||||
values.sort((left, right) => left - right);
|
||||
return values[Math.floor(values.length / 2)] ?? 255;
|
||||
};
|
||||
// 局部语义块会紧贴文字裁剪;连续渐变会把同一背景拆散到许多颜色桶,
|
||||
// 使深色文字反而成为“众数背景”。边框中位色能稳定覆盖纯色和渐变,
|
||||
// 且不会被少量触边字形反转。
|
||||
const border = {
|
||||
red: median(red),
|
||||
green: median(green),
|
||||
blue: median(blue),
|
||||
};
|
||||
const robustRange = (values: readonly number[]) =>
|
||||
(values[Math.floor(values.length * 0.8)] ?? 255) -
|
||||
(values[Math.floor(values.length * 0.2)] ?? 255);
|
||||
const borderChannelRange = Math.max(
|
||||
robustRange(red),
|
||||
robustRange(green),
|
||||
robustRange(blue),
|
||||
);
|
||||
// 极紧的字形裁剪可能四边都落在抗锯齿像素上;实心表头等
|
||||
// 盒背景也可能在裁剪边界外露出白色。因此保留全图众数作为常规背景。
|
||||
const buckets = new Map<
|
||||
number,
|
||||
{ count: number; red: number; green: number; blue: number }
|
||||
@@ -78,10 +156,12 @@ function rasterBackgroundColor(
|
||||
| { 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 pixelRed = rgba[offset] ?? 255;
|
||||
const pixelGreen = rgba[offset + 1] ?? 255;
|
||||
const pixelBlue = rgba[offset + 2] ?? 255;
|
||||
const bucketKey = (pixelRed >> 3) << 10 |
|
||||
(pixelGreen >> 3) << 5 |
|
||||
(pixelBlue >> 3);
|
||||
const bucket = buckets.get(bucketKey) ?? {
|
||||
count: 0,
|
||||
red: 0,
|
||||
@@ -89,21 +169,28 @@ function rasterBackgroundColor(
|
||||
blue: 0,
|
||||
};
|
||||
bucket.count += 1;
|
||||
bucket.red += red;
|
||||
bucket.green += green;
|
||||
bucket.blue += blue;
|
||||
bucket.red += pixelRed;
|
||||
bucket.green += pixelGreen;
|
||||
bucket.blue += pixelBlue;
|
||||
buckets.set(bucketKey, bucket);
|
||||
if (!dominant || bucket.count > dominant.count) {
|
||||
dominant = bucket;
|
||||
}
|
||||
}
|
||||
return !dominant
|
||||
? { red: 255, green: 255, blue: 255 }
|
||||
: {
|
||||
const dominantColor = dominant
|
||||
? {
|
||||
red: dominant.red / dominant.count,
|
||||
green: dominant.green / dominant.count,
|
||||
blue: dominant.blue / dominant.count,
|
||||
};
|
||||
}
|
||||
: border;
|
||||
// 只有浅色边界与全图众数出现极大亮度反转时,才判定连续渐变
|
||||
// 把深色文字拆分成了错误的“众数背景”。
|
||||
return borderChannelRange <= 64 &&
|
||||
colorLuminance(border) >= 220 &&
|
||||
colorLuminance(dominantColor) <= colorLuminance(border) - 80
|
||||
? border
|
||||
: dominantColor;
|
||||
}
|
||||
|
||||
function rasterForegroundColor(
|
||||
@@ -172,6 +259,183 @@ function rasterForegroundColor(
|
||||
};
|
||||
}
|
||||
|
||||
function rasterDominantForegroundColor(
|
||||
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,
|
||||
};
|
||||
if (colorDistance(color, background) < 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);
|
||||
}
|
||||
const dominant = Array.from(buckets.values()).sort(
|
||||
(left, right) => right.count - left.count,
|
||||
)[0];
|
||||
return !dominant
|
||||
? background
|
||||
: {
|
||||
red: dominant.red / dominant.count,
|
||||
green: dominant.green / dominant.count,
|
||||
blue: dominant.blue / dominant.count,
|
||||
};
|
||||
}
|
||||
|
||||
function rasterDominantChromaticForegroundColor(
|
||||
decoded: DecodedRaster,
|
||||
): RgbColor | undefined {
|
||||
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,
|
||||
};
|
||||
if (
|
||||
colorDistance(color, background) < 48 ||
|
||||
colorChroma(color) < 32
|
||||
) {
|
||||
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);
|
||||
}
|
||||
const dominant = Array.from(buckets.values()).sort(
|
||||
(left, right) => right.count - left.count,
|
||||
)[0];
|
||||
return dominant
|
||||
? {
|
||||
red: dominant.red / dominant.count,
|
||||
green: dominant.green / dominant.count,
|
||||
blue: dominant.blue / dominant.count,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function rasterChromaticPalette(decoded: DecodedRaster) {
|
||||
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,
|
||||
};
|
||||
if (
|
||||
colorDistance(color, background) < 48 ||
|
||||
colorChroma(color) < 32
|
||||
) {
|
||||
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);
|
||||
}
|
||||
return Array.from(buckets.values()).map((bucket) => ({
|
||||
count: bucket.count,
|
||||
color: {
|
||||
red: bucket.red / bucket.count,
|
||||
green: bucket.green / bucket.count,
|
||||
blue: bucket.blue / bucket.count,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function chromaticPaletteDistance(
|
||||
baseline: DecodedRaster,
|
||||
candidate: DecodedRaster,
|
||||
) {
|
||||
const baselinePalette = rasterChromaticPalette(baseline);
|
||||
const candidatePalette = rasterChromaticPalette(candidate);
|
||||
if (baselinePalette.length === 0 || candidatePalette.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const directionalDistance = (
|
||||
source: ReturnType<typeof rasterChromaticPalette>,
|
||||
target: ReturnType<typeof rasterChromaticPalette>,
|
||||
) => {
|
||||
let weightedDistance = 0;
|
||||
let totalWeight = 0;
|
||||
for (const entry of source) {
|
||||
weightedDistance += entry.count * Math.min(
|
||||
...target.map((candidateEntry) =>
|
||||
colorDistance(entry.color, candidateEntry.color)
|
||||
),
|
||||
);
|
||||
totalWeight += entry.count;
|
||||
}
|
||||
return weightedDistance / totalWeight;
|
||||
};
|
||||
return Math.max(
|
||||
directionalDistance(baselinePalette, candidatePalette),
|
||||
directionalDistance(candidatePalette, baselinePalette),
|
||||
);
|
||||
}
|
||||
|
||||
function clearMaskBorder(
|
||||
mask: Uint8Array,
|
||||
width: number,
|
||||
@@ -427,16 +691,16 @@ export async function comparePageRasters(
|
||||
!Number.isSafeInteger(height) || height <= 0) {
|
||||
throw new Error("栅格比较尺寸必须是正整数");
|
||||
}
|
||||
const [baselineDecoded, candidateDecoded] = await Promise.all([
|
||||
decodeRaster(baseline, width, height),
|
||||
decodeRaster(candidate, width, height),
|
||||
]);
|
||||
const baselineDecoded = await decodeRaster(baseline, width, height);
|
||||
const candidateDecoded = await 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 baselineForegroundInk = new Uint8Array(pixelCount);
|
||||
const candidateForegroundInk = new Uint8Array(pixelCount);
|
||||
const baselineBackground = rasterBackgroundColor(
|
||||
baselineDecoded.rgba,
|
||||
width,
|
||||
@@ -447,6 +711,8 @@ export async function comparePageRasters(
|
||||
width,
|
||||
height,
|
||||
);
|
||||
const baselineForeground = rasterForegroundColor(baselineDecoded);
|
||||
const candidateForeground = rasterForegroundColor(candidateDecoded);
|
||||
let absoluteError = 0;
|
||||
let changedPixels = 0;
|
||||
|
||||
@@ -494,10 +760,34 @@ export async function comparePageRasters(
|
||||
if (colorDistance(candidateColor, candidateBackground) >= 16) {
|
||||
candidateInk[pixelIndex] = 1;
|
||||
}
|
||||
const baselineBackgroundDistance = colorDistance(
|
||||
baselineColor,
|
||||
baselineBackground,
|
||||
);
|
||||
if (
|
||||
baselineBackgroundDistance >= 32 &&
|
||||
colorDistance(baselineColor, baselineForeground) <
|
||||
baselineBackgroundDistance
|
||||
) {
|
||||
baselineForegroundInk[pixelIndex] = 1;
|
||||
}
|
||||
const candidateBackgroundDistance = colorDistance(
|
||||
candidateColor,
|
||||
candidateBackground,
|
||||
);
|
||||
if (
|
||||
candidateBackgroundDistance >= 32 &&
|
||||
colorDistance(candidateColor, candidateForeground) <
|
||||
candidateBackgroundDistance
|
||||
) {
|
||||
candidateForegroundInk[pixelIndex] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
clearMaskBorder(baselineInk, width, height);
|
||||
clearMaskBorder(candidateInk, width, height);
|
||||
clearMaskBorder(baselineForegroundInk, width, height);
|
||||
clearMaskBorder(candidateForegroundInk, width, height);
|
||||
const baselineEdges = clearMaskBorder(
|
||||
createEdgeMask(baselineGray, width, height, 40),
|
||||
width,
|
||||
@@ -509,8 +799,27 @@ export async function comparePageRasters(
|
||||
height,
|
||||
);
|
||||
const foregroundColorDelta = colorDistance(
|
||||
rasterForegroundColor(baselineDecoded),
|
||||
rasterForegroundColor(candidateDecoded),
|
||||
baselineForeground,
|
||||
candidateForeground,
|
||||
);
|
||||
const dominantForegroundColorDelta = colorDistance(
|
||||
rasterDominantForegroundColor(baselineDecoded),
|
||||
rasterDominantForegroundColor(candidateDecoded),
|
||||
);
|
||||
const baselineChromaticForeground =
|
||||
rasterDominantChromaticForegroundColor(baselineDecoded);
|
||||
const candidateChromaticForeground =
|
||||
rasterDominantChromaticForegroundColor(candidateDecoded);
|
||||
const chromaticForegroundColorDelta =
|
||||
baselineChromaticForeground && candidateChromaticForeground
|
||||
? colorDistance(
|
||||
baselineChromaticForeground,
|
||||
candidateChromaticForeground,
|
||||
)
|
||||
: undefined;
|
||||
const chromaticPaletteDelta = chromaticPaletteDistance(
|
||||
baselineDecoded,
|
||||
candidateDecoded,
|
||||
);
|
||||
const backgroundColorDelta = colorDistance(
|
||||
baselineBackground,
|
||||
@@ -537,6 +846,13 @@ export async function comparePageRasters(
|
||||
height,
|
||||
spatialTolerancePx,
|
||||
),
|
||||
foregroundInkIou: calculateSpatiallyTolerantIou(
|
||||
baselineForegroundInk,
|
||||
candidateForegroundInk,
|
||||
width,
|
||||
height,
|
||||
spatialTolerancePx,
|
||||
),
|
||||
edgeIou: calculateSpatiallyTolerantIou(
|
||||
baselineEdges,
|
||||
candidateEdges,
|
||||
@@ -546,6 +862,19 @@ export async function comparePageRasters(
|
||||
),
|
||||
backgroundColorDelta,
|
||||
foregroundColorDelta,
|
||||
baselineForegroundChroma: colorChroma(baselineForeground),
|
||||
candidateForegroundChroma: colorChroma(candidateForeground),
|
||||
foregroundLuminanceDelta: Math.abs(
|
||||
colorLuminance(baselineForeground) -
|
||||
colorLuminance(candidateForeground),
|
||||
),
|
||||
dominantForegroundColorDelta,
|
||||
...(chromaticForegroundColorDelta !== undefined
|
||||
? { chromaticForegroundColorDelta }
|
||||
: {}),
|
||||
...(chromaticPaletteDelta !== undefined
|
||||
? { chromaticPaletteDelta }
|
||||
: {}),
|
||||
};
|
||||
return {
|
||||
metrics,
|
||||
|
||||
Reference in New Issue
Block a user