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:
SkyJourney
2026-08-26 10:50:20 +08:00
parent 01b06abc2c
commit 64445322eb
75 changed files with 9255 additions and 298 deletions
+350 -21
View File
@@ -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,