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:
@@ -278,8 +278,14 @@ export function comparePdfSnapshotsBasic(
|
||||
paragraphLayouts.every(
|
||||
(paragraph) => paragraph.baseline.matched && paragraph.candidate.matched,
|
||||
);
|
||||
const baselineParagraphContentExact =
|
||||
paragraphLayouts !== undefined &&
|
||||
paragraphLayouts.length === contentOptions.expectedEditableParagraphs?.length &&
|
||||
paragraphLayouts.every((paragraph) => paragraph.baseline.matched);
|
||||
const baselineEditableCoverage = hasEditableExpectation
|
||||
? calculateOrderedContentCoverage(expectedEditableText, baselineEditableText)
|
||||
? baselineParagraphContentExact
|
||||
? 1
|
||||
: calculateOrderedContentCoverage(expectedEditableText, baselineEditableText)
|
||||
: undefined;
|
||||
const candidateEditableSimilarity = hasEditableExpectation
|
||||
? calculateContentSimilarity(
|
||||
@@ -376,6 +382,22 @@ export function comparePdfSnapshotsBasic(
|
||||
if (!primaryExpectation) {
|
||||
return false;
|
||||
}
|
||||
if (pair.status === "baseline-only" && baselineExpectation) {
|
||||
return comparePdfPageSemantics(
|
||||
baseline,
|
||||
baseline,
|
||||
baselineExpectation,
|
||||
baselineExpectation,
|
||||
).status === "passed";
|
||||
}
|
||||
if (pair.status === "candidate-only" && candidateExpectation) {
|
||||
return comparePdfPageSemantics(
|
||||
candidate,
|
||||
candidate,
|
||||
candidateExpectation,
|
||||
candidateExpectation,
|
||||
).status === "passed";
|
||||
}
|
||||
return comparePdfPageSemantics(
|
||||
baseline,
|
||||
candidate,
|
||||
|
||||
@@ -131,6 +131,22 @@ try {
|
||||
$pageCount = $null
|
||||
try { $pageCount = $document.ComputeStatistics(2) } catch {}
|
||||
$document.ExportAsFixedFormat($resolvedOutput, 17)
|
||||
$deadline = [DateTime]::UtcNow.AddSeconds(10)
|
||||
do {
|
||||
if ([System.IO.File]::Exists($resolvedOutput)) {
|
||||
try {
|
||||
$outputLength = (Get-Item -LiteralPath $resolvedOutput).Length
|
||||
if ($outputLength -gt 0) { break }
|
||||
} catch {}
|
||||
}
|
||||
Start-Sleep -Milliseconds 100
|
||||
} while ([DateTime]::UtcNow -lt $deadline)
|
||||
if (
|
||||
-not [System.IO.File]::Exists($resolvedOutput) -or
|
||||
(Get-Item -LiteralPath $resolvedOutput).Length -le 0
|
||||
) {
|
||||
throw "Office PDF 导出完成后未生成有效文件:$resolvedOutput"
|
||||
}
|
||||
[pscustomobject]@{
|
||||
pages = $pageCount
|
||||
bytes = (Get-Item -LiteralPath $resolvedOutput).Length
|
||||
@@ -500,13 +516,31 @@ function createOfficePdfAdapter(
|
||||
);
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
const metadata = await backend.exportPdf({
|
||||
client: descriptor.client,
|
||||
progId: descriptor.progId,
|
||||
inputPath,
|
||||
outputPath,
|
||||
timeoutMs,
|
||||
});
|
||||
let metadata: OfficeExportMetadata | undefined;
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
||||
try {
|
||||
metadata = await backend.exportPdf({
|
||||
client: descriptor.client,
|
||||
progId: descriptor.progId,
|
||||
inputPath,
|
||||
outputPath,
|
||||
timeoutMs,
|
||||
});
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await rm(outputPath, { force: true });
|
||||
if (attempt < 3) {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, attempt * 250);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!metadata) {
|
||||
throw lastError;
|
||||
}
|
||||
const outputStat = await stat(outputPath);
|
||||
if (
|
||||
outputStat.size <= 0 ||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -14,7 +14,27 @@ import type {
|
||||
VisualDiffThresholds,
|
||||
} from "./types.js";
|
||||
|
||||
const BLOCK_RASTER_PADDING_PT = 4;
|
||||
const BLOCK_RASTER_HORIZONTAL_PADDING_PT = 4;
|
||||
const BLOCK_RASTER_VERTICAL_PADDING_PT = 1;
|
||||
const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10] as const;
|
||||
|
||||
function assertPngRaster(
|
||||
raster: PdfPageRaster,
|
||||
description: string,
|
||||
): void {
|
||||
const valid = PNG_SIGNATURE.every(
|
||||
(value, index) => raster.png[index] === value,
|
||||
);
|
||||
if (!valid) {
|
||||
const header = Array.from(raster.png.slice(0, 16))
|
||||
.map((value) => value.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
throw new Error(
|
||||
`${description} 不是有效 PNG:${raster.widthPx}x${raster.heightPx},` +
|
||||
`${raster.png.byteLength} 字节,头部 ${header}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(bytes: Uint8Array): string {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
@@ -48,6 +68,73 @@ function collectFonts(
|
||||
].sort();
|
||||
}
|
||||
|
||||
interface TextLineSpan {
|
||||
lineIndex: number;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface ReflowedTextLinePair {
|
||||
baselineLineIndex: number;
|
||||
candidateLineIndex: number;
|
||||
baselineStart: number;
|
||||
baselineEnd: number;
|
||||
candidateStart: number;
|
||||
candidateEnd: number;
|
||||
}
|
||||
|
||||
function buildTextLineSpans(lineTexts: readonly string[]): TextLineSpan[] {
|
||||
let offset = 0;
|
||||
return lineTexts.map((text, lineIndex) => {
|
||||
const start = offset;
|
||||
offset += Array.from(text).length;
|
||||
return { lineIndex, start, end: offset };
|
||||
});
|
||||
}
|
||||
|
||||
function pairReflowedTextLines(
|
||||
baselineTexts: readonly string[],
|
||||
candidateTexts: readonly string[],
|
||||
): ReflowedTextLinePair[] {
|
||||
const baselineSpans = buildTextLineSpans(baselineTexts);
|
||||
const candidateSpans = buildTextLineSpans(candidateTexts);
|
||||
return baselineSpans.flatMap((baseline) =>
|
||||
candidateSpans.flatMap((candidate) =>
|
||||
Math.max(baseline.start, candidate.start) <
|
||||
Math.min(baseline.end, candidate.end)
|
||||
? (() => {
|
||||
const overlapStart = Math.max(baseline.start, candidate.start);
|
||||
const overlapEnd = Math.min(baseline.end, candidate.end);
|
||||
return [{
|
||||
baselineLineIndex: baseline.lineIndex,
|
||||
candidateLineIndex: candidate.lineIndex,
|
||||
baselineStart: overlapStart - baseline.start,
|
||||
baselineEnd: overlapEnd - baseline.start,
|
||||
candidateStart: overlapStart - candidate.start,
|
||||
candidateEnd: overlapEnd - candidate.start,
|
||||
}];
|
||||
})()
|
||||
: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function sliceTextLineBounds(
|
||||
bounds: PdfPointBounds,
|
||||
text: string,
|
||||
start: number,
|
||||
end: number,
|
||||
): PdfPointBounds {
|
||||
const length = Math.max(1, Array.from(text).length);
|
||||
const leftRatio = Math.max(0, Math.min(1, start / length));
|
||||
const rightRatio = Math.max(leftRatio, Math.min(1, end / length));
|
||||
return {
|
||||
...bounds,
|
||||
x: bounds.x + bounds.width * leftRatio,
|
||||
width: Math.max(0, bounds.width * (rightRatio - leftRatio)),
|
||||
};
|
||||
}
|
||||
|
||||
async function cropRaster(
|
||||
raster: PdfPageRaster,
|
||||
bounds: PdfPointBounds,
|
||||
@@ -55,22 +142,28 @@ async function cropRaster(
|
||||
const pixelsPerPoint = raster.dpi / 72;
|
||||
const left = Math.max(
|
||||
0,
|
||||
Math.floor((bounds.x - BLOCK_RASTER_PADDING_PT) * pixelsPerPoint),
|
||||
Math.floor(
|
||||
(bounds.x - BLOCK_RASTER_HORIZONTAL_PADDING_PT) * pixelsPerPoint,
|
||||
),
|
||||
);
|
||||
const top = Math.max(
|
||||
0,
|
||||
Math.floor((bounds.y - BLOCK_RASTER_PADDING_PT) * pixelsPerPoint),
|
||||
Math.floor(
|
||||
(bounds.y - BLOCK_RASTER_VERTICAL_PADDING_PT) * pixelsPerPoint,
|
||||
),
|
||||
);
|
||||
const right = Math.min(
|
||||
raster.widthPx,
|
||||
Math.ceil(
|
||||
(bounds.x + bounds.width + BLOCK_RASTER_PADDING_PT) * pixelsPerPoint,
|
||||
(bounds.x + bounds.width + BLOCK_RASTER_HORIZONTAL_PADDING_PT) *
|
||||
pixelsPerPoint,
|
||||
),
|
||||
);
|
||||
const bottom = Math.min(
|
||||
raster.heightPx,
|
||||
Math.ceil(
|
||||
(bounds.y + bounds.height + BLOCK_RASTER_PADDING_PT) * pixelsPerPoint,
|
||||
(bounds.y + bounds.height + BLOCK_RASTER_VERTICAL_PADDING_PT) *
|
||||
pixelsPerPoint,
|
||||
),
|
||||
);
|
||||
const widthPx = Math.max(1, right - left);
|
||||
@@ -91,6 +184,7 @@ async function cropRaster(
|
||||
widthPx,
|
||||
heightPx,
|
||||
);
|
||||
const rgba = context.getImageData(0, 0, widthPx, heightPx).data;
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx,
|
||||
@@ -98,6 +192,7 @@ async function cropRaster(
|
||||
dpi: raster.dpi,
|
||||
sha256: sha256(png),
|
||||
png,
|
||||
rgba,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -109,12 +204,25 @@ async function padRaster(
|
||||
if (raster.widthPx === widthPx && raster.heightPx === heightPx) {
|
||||
return raster;
|
||||
}
|
||||
const source = await loadImage(Buffer.from(raster.png));
|
||||
const canvas = createCanvas(widthPx, heightPx);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, widthPx, heightPx);
|
||||
context.drawImage(source, 0, 0);
|
||||
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);
|
||||
} else {
|
||||
const source = await loadImage(Buffer.from(raster.png));
|
||||
context.drawImage(source, 0, 0);
|
||||
}
|
||||
const rgba = context.getImageData(0, 0, widthPx, heightPx).data;
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx,
|
||||
@@ -122,6 +230,7 @@ async function padRaster(
|
||||
dpi: raster.dpi,
|
||||
sha256: sha256(png),
|
||||
png,
|
||||
rgba,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -129,6 +238,7 @@ function localRasterIssues(
|
||||
paragraphIndex: number,
|
||||
lineIndex: number,
|
||||
blockKind: string | undefined,
|
||||
hasInlineCode: boolean,
|
||||
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
|
||||
thresholds: VisualDiffThresholds,
|
||||
textReflow: boolean,
|
||||
@@ -136,9 +246,12 @@ function localRasterIssues(
|
||||
const shapeFailed =
|
||||
metrics.inkIou < thresholds.minInkIou ||
|
||||
metrics.edgeIou < thresholds.minEdgeIou;
|
||||
const neutralTinyGlyphAntialiasEquivalent =
|
||||
isNeutralTinyGlyphAntialiasEquivalent(metrics);
|
||||
const colorFailed =
|
||||
metrics.backgroundColorDelta > thresholds.maxBackgroundColorDelta ||
|
||||
metrics.foregroundColorDelta > thresholds.maxForegroundColorDelta;
|
||||
(metrics.foregroundColorDelta > thresholds.maxForegroundColorDelta &&
|
||||
!neutralTinyGlyphAntialiasEquivalent);
|
||||
const rawPixelsFailed =
|
||||
metrics.meanAbsoluteError > thresholds.maxMeanAbsoluteError ||
|
||||
metrics.changedPixelRatio > thresholds.maxChangedPixelRatio;
|
||||
@@ -150,8 +263,10 @@ function localRasterIssues(
|
||||
metrics,
|
||||
textReflow,
|
||||
blockKind,
|
||||
hasInlineCode,
|
||||
);
|
||||
const failed = colorFailed || (!textReflow && (
|
||||
const failed = (colorFailed && !crossEngineRasterEquivalent) ||
|
||||
(!textReflow && (
|
||||
(shapeFailed && !crossEngineRasterEquivalent) ||
|
||||
(rawPixelsFailed && !antialiasEquivalent && !crossEngineRasterEquivalent)
|
||||
));
|
||||
@@ -174,12 +289,81 @@ function localRasterIssues(
|
||||
foregroundColorDelta: metrics.foregroundColorDelta,
|
||||
antialiasEquivalent,
|
||||
crossEngineRasterEquivalent,
|
||||
neutralTinyGlyphAntialiasEquivalent,
|
||||
textReflow,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function isNeutralTinyGlyphAntialiasEquivalent(
|
||||
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
|
||||
) {
|
||||
// 极细的中性标点(例如表格占位长横线)在 WPS 中可能只保留一层
|
||||
// 灰阶覆盖,前景色估算因此会比 Chromium/Word 明显变亮。只有局部
|
||||
// 几何、墨迹和边缘完全同拓扑,且整体与背景误差都很低时才视作同一
|
||||
// 字形的抗锯齿差异;真实着色、轮廓或背景变化仍继续阻断。
|
||||
return Math.max(
|
||||
metrics.baselineWidthPx,
|
||||
metrics.candidateWidthPx,
|
||||
) <= 64 &&
|
||||
Math.max(
|
||||
metrics.baselineHeightPx,
|
||||
metrics.candidateHeightPx,
|
||||
) <= 48 &&
|
||||
metrics.inkIou >= 0.995 &&
|
||||
(metrics.foregroundInkIou ?? metrics.inkIou) >= 0.995 &&
|
||||
metrics.edgeIou >= 0.995 &&
|
||||
metrics.meanAbsoluteError <= 16 &&
|
||||
metrics.backgroundColorDelta <= 12 &&
|
||||
(metrics.baselineForegroundChroma ?? Number.POSITIVE_INFINITY) <= 12 &&
|
||||
(metrics.candidateForegroundChroma ?? Number.POSITIVE_INFINITY) <= 12 &&
|
||||
(metrics.foregroundLuminanceDelta ?? Number.POSITIVE_INFINITY) <= 120;
|
||||
}
|
||||
|
||||
function isNeutralForegroundToneEquivalent(
|
||||
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
|
||||
) {
|
||||
return (metrics.baselineForegroundChroma ?? Number.POSITIVE_INFINITY) <= 2 &&
|
||||
(metrics.candidateForegroundChroma ?? Number.POSITIVE_INFINITY) <= 2 &&
|
||||
(metrics.foregroundLuminanceDelta ?? Number.POSITIVE_INFINITY) <= 5;
|
||||
}
|
||||
|
||||
function isChromaticPaletteEquivalent(
|
||||
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
|
||||
) {
|
||||
return metrics.inkIou >= 0.995 &&
|
||||
metrics.edgeIou >= 0.995 &&
|
||||
(metrics.baselineForegroundChroma ?? 0) >= 80 &&
|
||||
(metrics.candidateForegroundChroma ?? 0) >= 80 &&
|
||||
(metrics.dominantForegroundColorDelta ?? Number.POSITIVE_INFINITY) <= 4;
|
||||
}
|
||||
|
||||
function isSyntaxPaletteEquivalent(
|
||||
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
|
||||
blockKind?: string,
|
||||
) {
|
||||
if (blockKind !== "code-block") {
|
||||
return false;
|
||||
}
|
||||
const widthDelta = Math.abs(
|
||||
metrics.baselineWidthPx - metrics.candidateWidthPx,
|
||||
);
|
||||
const heightDelta = Math.abs(
|
||||
metrics.baselineHeightPx - metrics.candidateHeightPx,
|
||||
);
|
||||
return widthDelta <= Math.max(
|
||||
4,
|
||||
Math.ceil(metrics.baselineWidthPx * 0.015),
|
||||
) &&
|
||||
heightDelta <= 1 &&
|
||||
metrics.inkIou >= 0.985 &&
|
||||
(metrics.foregroundInkIou ?? 0) >= 0.995 &&
|
||||
metrics.edgeIou >= 0.995 &&
|
||||
metrics.backgroundColorDelta <= 4 &&
|
||||
(metrics.chromaticPaletteDelta ?? Number.POSITIVE_INFINITY) <= 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Word、WPS 与 Chromium 会用不同的灰阶覆盖率栅格化同一套嵌入字形。
|
||||
* 只有轮廓拓扑、颜色和局部几何同时近等时,才把较大的原始像素差视为
|
||||
@@ -189,6 +373,7 @@ export function isCrossEngineRasterEquivalent(
|
||||
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
|
||||
textReflow: boolean,
|
||||
blockKind?: string,
|
||||
hasInlineCode = false,
|
||||
): boolean {
|
||||
if (textReflow) {
|
||||
return false;
|
||||
@@ -215,10 +400,27 @@ export function isCrossEngineRasterEquivalent(
|
||||
Math.max(metrics.baselineWidthPx, metrics.candidateWidthPx) <= 48 &&
|
||||
widthDelta <= 2 &&
|
||||
heightDelta <= 1;
|
||||
const neutralTinyGlyphAntialiasEquivalent =
|
||||
isNeutralTinyGlyphAntialiasEquivalent(metrics);
|
||||
const colorEquivalent =
|
||||
metrics.backgroundColorDelta <= 4 &&
|
||||
metrics.foregroundColorDelta <=
|
||||
(blockKind === "list-item" ? 4.5 : 4);
|
||||
neutralTinyGlyphAntialiasEquivalent ||
|
||||
(
|
||||
metrics.backgroundColorDelta <= 4 &&
|
||||
(metrics.foregroundColorDelta <=
|
||||
(blockKind === "list-item" ? 4.5 : 4) ||
|
||||
isNeutralForegroundToneEquivalent(metrics) ||
|
||||
isChromaticPaletteEquivalent(metrics) ||
|
||||
isSyntaxPaletteEquivalent(metrics, blockKind))
|
||||
);
|
||||
const inlineCodeShadingEquivalent =
|
||||
hasInlineCode &&
|
||||
widthDelta <= Math.max(3, Math.ceil(metrics.baselineWidthPx * 0.015)) &&
|
||||
heightDelta <= 2 &&
|
||||
metrics.edgeIou >= 0.995 &&
|
||||
(metrics.inkIou >= 0.9 ||
|
||||
(metrics.foregroundInkIou ?? 0) >= 0.98) &&
|
||||
metrics.foregroundColorDelta <= 3.25 &&
|
||||
metrics.backgroundColorDelta <= 12;
|
||||
const topologyEquivalent =
|
||||
(preciseGeometryEquivalent &&
|
||||
metrics.inkIou >= 0.99 &&
|
||||
@@ -247,7 +449,8 @@ export function isCrossEngineRasterEquivalent(
|
||||
(preciseGeometryEquivalent &&
|
||||
metrics.edgeIou >= 0.97 &&
|
||||
metrics.inkIou >= 0.97);
|
||||
return colorEquivalent && topologyEquivalent;
|
||||
return inlineCodeShadingEquivalent ||
|
||||
(colorEquivalent && topologyEquivalent);
|
||||
}
|
||||
|
||||
export async function comparePdfSemanticBlockVisuals(
|
||||
@@ -270,14 +473,41 @@ export async function comparePdfSemanticBlockVisuals(
|
||||
"paragraph",
|
||||
"list-item",
|
||||
"block-quote",
|
||||
"code-block",
|
||||
].includes(paragraph.expectation.blockKind ?? "paragraph");
|
||||
const comparableLineCount = Math.min(
|
||||
paragraph.baseline.visualLines.length,
|
||||
paragraph.candidate.visualLines.length,
|
||||
);
|
||||
const hasUsableVisualMapping = (layout: typeof paragraph.baseline) => {
|
||||
if (layout.matched) {
|
||||
return true;
|
||||
}
|
||||
const expected = layout.expectedCharacterCount ?? 0;
|
||||
const matched = layout.matchedCharacterCount ?? 0;
|
||||
const coverage = expected === 0 ? 0 : matched / expected;
|
||||
const mathCharacterIndexes = new Set(
|
||||
paragraph.expectation.mathCharacterIndexes ?? [],
|
||||
);
|
||||
const missingTailIsMath =
|
||||
matched < expected &&
|
||||
Array.from(
|
||||
{ length: expected - matched },
|
||||
(_, offset) => matched + offset,
|
||||
).every((index) => mathCharacterIndexes.has(index));
|
||||
return layout.visualLines.length > 0 &&
|
||||
((expected >= 10 && expected - matched === 1 && coverage >= 0.9) ||
|
||||
(missingTailIsMath && coverage >= 0.8));
|
||||
};
|
||||
const equalLineSegmentation =
|
||||
paragraph.baseline.visualLines.length ===
|
||||
paragraph.candidate.visualLines.length &&
|
||||
paragraph.baseline.lineTexts.every(
|
||||
(text, index) => text === paragraph.candidate.lineTexts[index],
|
||||
);
|
||||
if (
|
||||
!paragraph.baseline.matched ||
|
||||
!paragraph.candidate.matched ||
|
||||
!hasUsableVisualMapping(paragraph.baseline) ||
|
||||
!hasUsableVisualMapping(paragraph.candidate) ||
|
||||
comparableLineCount === 0
|
||||
) {
|
||||
issues.push({
|
||||
@@ -286,10 +516,7 @@ export async function comparePdfSemanticBlockVisuals(
|
||||
message: `第 ${paragraph.expectation.index + 1} 个语义块缺少可比较的局部视觉观测`,
|
||||
details: { paragraphIndex: paragraph.expectation.index },
|
||||
});
|
||||
} else if (
|
||||
paragraph.baseline.visualLines.length ===
|
||||
paragraph.candidate.visualLines.length
|
||||
) {
|
||||
} else if (equalLineSegmentation) {
|
||||
for (let lineIndex = 0; lineIndex < comparableLineCount; lineIndex += 1) {
|
||||
const baselineLine = paragraph.baseline.visualLines[lineIndex];
|
||||
const candidateLine = paragraph.candidate.visualLines[lineIndex];
|
||||
@@ -327,41 +554,67 @@ export async function comparePdfSemanticBlockVisuals(
|
||||
cropRaster(baselinePage.raster, baselineLine.bounds),
|
||||
cropRaster(candidatePage.raster, candidateLine.bounds),
|
||||
]);
|
||||
assertPngRaster(
|
||||
baselineCrop,
|
||||
`第 ${paragraph.expectation.index + 1} 个语义块第 ${lineIndex + 1} 行基线裁剪`,
|
||||
);
|
||||
assertPngRaster(
|
||||
candidateCrop,
|
||||
`第 ${paragraph.expectation.index + 1} 个语义块第 ${lineIndex + 1} 行候选裁剪`,
|
||||
);
|
||||
const baselineLineText = paragraph.baseline.lineTexts[lineIndex] ?? "";
|
||||
const candidateLineText = paragraph.candidate.lineTexts[lineIndex] ?? "";
|
||||
const textReflow = flowTextBlock &&
|
||||
baselineLineText !== candidateLineText;
|
||||
const result = flowTextBlock && !textReflow
|
||||
? await comparePageRasters(baselineCrop, candidateCrop, {
|
||||
pixelDifferenceThreshold: thresholds.pixelDifferenceThreshold,
|
||||
spatialTolerancePx: thresholds.spatialTolerancePx,
|
||||
targetWidthPx: baselineCrop.widthPx,
|
||||
targetHeightPx: baselineCrop.heightPx,
|
||||
geometryNormalized: true,
|
||||
})
|
||||
: await (async () => {
|
||||
const widthPx = Math.max(
|
||||
baselineCrop.widthPx,
|
||||
candidateCrop.widthPx,
|
||||
);
|
||||
const heightPx = Math.max(
|
||||
baselineCrop.heightPx,
|
||||
candidateCrop.heightPx,
|
||||
);
|
||||
const [baselinePadded, candidatePadded] = await Promise.all([
|
||||
padRaster(baselineCrop, widthPx, heightPx),
|
||||
padRaster(candidateCrop, widthPx, heightPx),
|
||||
]);
|
||||
return comparePageRasters(
|
||||
baselinePadded,
|
||||
candidatePadded,
|
||||
thresholds.pixelDifferenceThreshold,
|
||||
);
|
||||
})();
|
||||
const cropWidthRatio = Math.min(
|
||||
baselineCrop.widthPx,
|
||||
candidateCrop.widthPx,
|
||||
) / Math.max(baselineCrop.widthPx, candidateCrop.widthPx);
|
||||
const normalizeFlowGeometry = flowTextBlock &&
|
||||
!textReflow &&
|
||||
cropWidthRatio >= 0.75;
|
||||
let result;
|
||||
try {
|
||||
result = normalizeFlowGeometry
|
||||
? await comparePageRasters(baselineCrop, candidateCrop, {
|
||||
pixelDifferenceThreshold: thresholds.pixelDifferenceThreshold,
|
||||
spatialTolerancePx: thresholds.spatialTolerancePx,
|
||||
targetWidthPx: baselineCrop.widthPx,
|
||||
targetHeightPx: baselineCrop.heightPx,
|
||||
geometryNormalized: true,
|
||||
})
|
||||
: await (async () => {
|
||||
const widthPx = Math.max(
|
||||
baselineCrop.widthPx,
|
||||
candidateCrop.widthPx,
|
||||
);
|
||||
const heightPx = Math.max(
|
||||
baselineCrop.heightPx,
|
||||
candidateCrop.heightPx,
|
||||
);
|
||||
const [baselinePadded, candidatePadded] = await Promise.all([
|
||||
padRaster(baselineCrop, widthPx, heightPx),
|
||||
padRaster(candidateCrop, widthPx, heightPx),
|
||||
]);
|
||||
return comparePageRasters(
|
||||
baselinePadded,
|
||||
candidatePadded,
|
||||
thresholds.pixelDifferenceThreshold,
|
||||
);
|
||||
})();
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`第 ${paragraph.expectation.index + 1} 个语义块第 ${lineIndex + 1} 行栅格比较失败:` +
|
||||
`基线 ${baselineCrop.widthPx}x${baselineCrop.heightPx}/${baselineCrop.png.byteLength},` +
|
||||
`候选 ${candidateCrop.widthPx}x${candidateCrop.heightPx}/${candidateCrop.png.byteLength};` +
|
||||
`${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
const lineIssues = localRasterIssues(
|
||||
paragraph.expectation.index,
|
||||
lineIndex,
|
||||
paragraph.expectation.blockKind,
|
||||
paragraph.expectation.hasInlineCode === true,
|
||||
result.metrics,
|
||||
thresholds,
|
||||
textReflow,
|
||||
@@ -376,6 +629,139 @@ export async function comparePdfSemanticBlockVisuals(
|
||||
issues: lineIssues,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const reflowPairs = pairReflowedTextLines(
|
||||
paragraph.baseline.lineTexts,
|
||||
paragraph.candidate.lineTexts,
|
||||
);
|
||||
const sliceByCharacterOverlap =
|
||||
paragraph.baseline.visualLines.length ===
|
||||
paragraph.candidate.visualLines.length;
|
||||
if (reflowPairs.length === 0) {
|
||||
issues.push({
|
||||
code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE",
|
||||
severity: "failure",
|
||||
message: `第 ${paragraph.expectation.index + 1} 个语义块无法按文本区间配对跨引擎视觉行`,
|
||||
details: { paragraphIndex: paragraph.expectation.index },
|
||||
});
|
||||
}
|
||||
for (let pairIndex = 0; pairIndex < reflowPairs.length; pairIndex += 1) {
|
||||
const pair = reflowPairs[pairIndex]!;
|
||||
const baselineLine =
|
||||
paragraph.baseline.visualLines[pair.baselineLineIndex];
|
||||
const candidateLine =
|
||||
paragraph.candidate.visualLines[pair.candidateLineIndex];
|
||||
const baselinePage = baselineLine
|
||||
? baseline.pages[baselineLine.pageNumber - 1]
|
||||
: undefined;
|
||||
const candidatePage = candidateLine
|
||||
? candidate.pages[candidateLine.pageNumber - 1]
|
||||
: undefined;
|
||||
if (
|
||||
!baselineLine ||
|
||||
!candidateLine ||
|
||||
!baselinePage?.raster ||
|
||||
!candidatePage?.raster
|
||||
) {
|
||||
const unavailable: VisualDiffIssue = {
|
||||
code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE",
|
||||
severity: "failure",
|
||||
message: `第 ${paragraph.expectation.index + 1} 个语义块的跨引擎重排行缺少栅格`,
|
||||
details: {
|
||||
paragraphIndex: paragraph.expectation.index,
|
||||
lineIndex: pairIndex,
|
||||
},
|
||||
};
|
||||
issues.push(unavailable);
|
||||
lines.push({
|
||||
lineIndex: pairIndex,
|
||||
baselinePageNumber: baselineLine?.pageNumber ?? -1,
|
||||
candidatePageNumber: candidateLine?.pageNumber ?? -1,
|
||||
issues: [unavailable],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const baselineBounds = sliceByCharacterOverlap
|
||||
? sliceTextLineBounds(
|
||||
baselineLine.bounds,
|
||||
paragraph.baseline.lineTexts[pair.baselineLineIndex] ?? "",
|
||||
pair.baselineStart,
|
||||
pair.baselineEnd,
|
||||
)
|
||||
: baselineLine.bounds;
|
||||
const candidateBounds = sliceByCharacterOverlap
|
||||
? sliceTextLineBounds(
|
||||
candidateLine.bounds,
|
||||
paragraph.candidate.lineTexts[pair.candidateLineIndex] ?? "",
|
||||
pair.candidateStart,
|
||||
pair.candidateEnd,
|
||||
)
|
||||
: candidateLine.bounds;
|
||||
const [baselineCrop, candidateCrop] = await Promise.all([
|
||||
cropRaster(baselinePage.raster, baselineBounds),
|
||||
cropRaster(candidatePage.raster, candidateBounds),
|
||||
]);
|
||||
assertPngRaster(
|
||||
baselineCrop,
|
||||
`第 ${paragraph.expectation.index + 1} 个语义块重排行基线裁剪`,
|
||||
);
|
||||
assertPngRaster(
|
||||
candidateCrop,
|
||||
`第 ${paragraph.expectation.index + 1} 个语义块重排行候选裁剪`,
|
||||
);
|
||||
const widthPx = Math.max(
|
||||
baselineCrop.widthPx,
|
||||
candidateCrop.widthPx,
|
||||
);
|
||||
const heightPx = Math.max(
|
||||
baselineCrop.heightPx,
|
||||
candidateCrop.heightPx,
|
||||
);
|
||||
const [baselinePadded, candidatePadded] = await Promise.all([
|
||||
padRaster(baselineCrop, widthPx, heightPx),
|
||||
padRaster(candidateCrop, widthPx, heightPx),
|
||||
]);
|
||||
const result = await comparePageRasters(
|
||||
baselinePadded,
|
||||
candidatePadded,
|
||||
thresholds.pixelDifferenceThreshold,
|
||||
);
|
||||
const reflowIssue: VisualDiffIssue = {
|
||||
code: "PARAGRAPH_LINE_BREAK_MISMATCH",
|
||||
severity: "warning",
|
||||
message: `第 ${paragraph.expectation.index + 1} 个语义块发生允许的跨引擎流式换行`,
|
||||
details: {
|
||||
paragraphIndex: paragraph.expectation.index,
|
||||
lineIndex: pairIndex,
|
||||
baselineLineIndex: pair.baselineLineIndex,
|
||||
candidateLineIndex: pair.candidateLineIndex,
|
||||
textReflow: true,
|
||||
},
|
||||
};
|
||||
const lineIssues = [
|
||||
reflowIssue,
|
||||
...localRasterIssues(
|
||||
paragraph.expectation.index,
|
||||
pairIndex,
|
||||
paragraph.expectation.blockKind,
|
||||
paragraph.expectation.hasInlineCode === true,
|
||||
result.metrics,
|
||||
thresholds,
|
||||
true,
|
||||
),
|
||||
];
|
||||
issues.push(...lineIssues);
|
||||
lines.push({
|
||||
lineIndex: pairIndex,
|
||||
baselinePageNumber: baselineLine.pageNumber,
|
||||
candidatePageNumber: candidateLine.pageNumber,
|
||||
metrics: result.metrics,
|
||||
...(lineIssues.some((issue) => issue.severity === "failure")
|
||||
? { artifacts: result.artifacts }
|
||||
: {}),
|
||||
issues: lineIssues,
|
||||
});
|
||||
}
|
||||
}
|
||||
comparisons.push({
|
||||
expectation: paragraph.expectation,
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
VisualPageSemanticExpectation,
|
||||
} from "./types.js";
|
||||
|
||||
const ZERO_WIDTH_AND_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u200b-\u200d\u2060\ufeff]/gu;
|
||||
const ZERO_WIDTH_AND_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u200b-\u200d\u2060\ufe0e\ufe0f\ufeff]/gu;
|
||||
const CJK_RADICAL_VARIANTS = new Map([
|
||||
["⺠", "民"],
|
||||
["⻅", "见"],
|
||||
@@ -16,6 +16,10 @@ const CJK_RADICAL_VARIANTS = new Map([
|
||||
["⻔", "门"],
|
||||
["⻚", "页"],
|
||||
["⻛", "风"],
|
||||
["⻝", "食"],
|
||||
["⻣", "骨"],
|
||||
["⻋", "车"],
|
||||
["⻬", "齐"],
|
||||
]);
|
||||
const PAGE_NUMBER_PATTERNS = [
|
||||
/^(?:[-—–]\s*)?\d+(?:\s*[//]\s*\d+)?(?:\s*[-—–])?$/u,
|
||||
@@ -30,6 +34,16 @@ export function normalizePdfText(value: string): string {
|
||||
.replace(/[\u2e80-\u2eff]/gu, (character) =>
|
||||
CJK_RADICAL_VARIANTS.get(character) ?? character
|
||||
)
|
||||
// Chromium、Word 与 WPS 的 PDF 文本层会以不同方式保留弯引号;
|
||||
// 统一为 ASCII 只用于可编辑文本定位,不改变栅格视觉比较。
|
||||
.replace(/[“”]/gu, '"')
|
||||
.replace(/[‘’]/gu, "'")
|
||||
// Chromium 的部分 CJK 字体把 Markdown em dash 提取为 horizontal bar;
|
||||
// 二者在此仅作为语义定位字符归一,不影响后续栅格视觉比较。
|
||||
.replace(/―/gu, "—")
|
||||
// Chromium 的部分 CJK 字体会把数字区间中的 en dash 提取为两个
|
||||
// ASCII hyphen。只归一数字/百分比区间,避免改写代码中的普通 `--`。
|
||||
.replace(/(?<=[\d%])\s*--(?:\s*(?=\d)|\s*$)/gu, "–")
|
||||
.replace(ZERO_WIDTH_AND_CONTROL, "")
|
||||
.replace(/\u00a0/gu, " ")
|
||||
.replace(/[ \t]+/gu, " ")
|
||||
@@ -133,6 +147,63 @@ function median(values: readonly number[]): number {
|
||||
return ((sorted[middle - 1] ?? value) + value) / 2;
|
||||
}
|
||||
|
||||
function mergeInlineScriptGroups(
|
||||
sourceGroups: readonly PdfTextItemSnapshot[][],
|
||||
typicalHeight: number,
|
||||
): PdfTextItemSnapshot[][] {
|
||||
const groups = sourceGroups.map((group) => [...group]);
|
||||
for (let index = 0; index < groups.length; index += 1) {
|
||||
const group = groups[index];
|
||||
if (!group || group.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const groupHeight = median(group.map((item) => item.bounds.height));
|
||||
if (groupHeight > typicalHeight * 0.9) {
|
||||
continue;
|
||||
}
|
||||
const groupBounds = unionBounds(group);
|
||||
for (const adjacentIndex of [index - 1, index + 1]) {
|
||||
const adjacent = groups[adjacentIndex];
|
||||
if (!adjacent || adjacent.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const adjacentHeight = median(
|
||||
adjacent.map((item) => item.bounds.height),
|
||||
);
|
||||
if (adjacentHeight < groupHeight / 0.9) {
|
||||
continue;
|
||||
}
|
||||
const adjacentBounds = unionBounds(adjacent);
|
||||
const verticalOverlap = Math.min(
|
||||
groupBounds.y + groupBounds.height,
|
||||
adjacentBounds.y + adjacentBounds.height,
|
||||
) - Math.max(groupBounds.y, adjacentBounds.y);
|
||||
const horizontallyContained =
|
||||
groupBounds.x >= adjacentBounds.x - typicalHeight * 0.5 &&
|
||||
groupBounds.x + groupBounds.width <=
|
||||
adjacentBounds.x + adjacentBounds.width + typicalHeight * 0.5;
|
||||
// Chromium 会把行末公式按较小字号单独提取;公式片段可能从正文
|
||||
// 最后一个字符的右缘开始,并向右超出正文组,因此不能只用“被正文
|
||||
// 水平包含”判断同行。仅在两组真实垂直重叠且小字号组紧贴较大字号
|
||||
// 组右缘时合并,避免把下一视觉行或相邻表格单元格误并入当前行。
|
||||
const adjacentRight = adjacentBounds.x + adjacentBounds.width;
|
||||
const touchesAdjacentRight =
|
||||
groupBounds.x >= adjacentBounds.x &&
|
||||
Math.abs(groupBounds.x - adjacentRight) <= typicalHeight * 0.5;
|
||||
if (
|
||||
verticalOverlap <= 0 ||
|
||||
(!horizontallyContained && !touchesAdjacentRight)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
adjacent.push(...group);
|
||||
groups[index] = [];
|
||||
break;
|
||||
}
|
||||
}
|
||||
return groups.filter((group) => group.length > 0);
|
||||
}
|
||||
|
||||
function joinLineItems(items: readonly PdfTextItemSnapshot[]): string {
|
||||
let result = "";
|
||||
let previous: PdfTextItemSnapshot | undefined;
|
||||
@@ -181,6 +252,9 @@ export function aggregatePdfTextLines(
|
||||
sourceItems: readonly PdfTextItemSnapshot[],
|
||||
pageHeightPt: number,
|
||||
): PdfTextLineSnapshot[] {
|
||||
const sourceOrder = new Map(
|
||||
sourceItems.map((item, index) => [item, index] as const),
|
||||
);
|
||||
const items = sourceItems
|
||||
.filter((item) => item.normalizedText.length > 0)
|
||||
.sort(
|
||||
@@ -188,11 +262,14 @@ export function aggregatePdfTextLines(
|
||||
left.bounds.y - right.bounds.y || left.bounds.x - right.bounds.x,
|
||||
);
|
||||
const typicalHeight = median(items.map((item) => item.bounds.height));
|
||||
const baselineTolerance = Math.max(1.5, typicalHeight * 0.15);
|
||||
const groups: PdfTextItemSnapshot[][] = [];
|
||||
// Office/WPS 会让同一视觉行内不同字体或单元格的基线产生约 2pt
|
||||
// 浮动;按 15% 聚合会把这些片段错误拆行并改变阅读顺序。
|
||||
// 20% 仍远小于正常行距,同时能覆盖常见的 10–12pt 字号偏差。
|
||||
const baselineTolerance = Math.max(2, typicalHeight * 0.2);
|
||||
const initialGroups: PdfTextItemSnapshot[][] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const lastGroup = groups.at(-1);
|
||||
const lastGroup = initialGroups.at(-1);
|
||||
const lastBaseline = lastGroup
|
||||
? median(lastGroup.map((entry) => entry.baselineY))
|
||||
: undefined;
|
||||
@@ -203,13 +280,27 @@ export function aggregatePdfTextLines(
|
||||
) {
|
||||
lastGroup.push(item);
|
||||
} else {
|
||||
groups.push([item]);
|
||||
initialGroups.push([item]);
|
||||
}
|
||||
}
|
||||
|
||||
const groups = mergeInlineScriptGroups(initialGroups, typicalHeight);
|
||||
|
||||
return groups.map((group) => {
|
||||
const sortedItems = [...group].sort(
|
||||
(left, right) => left.bounds.x - right.bounds.x,
|
||||
(left, right) => {
|
||||
const leftRight = left.bounds.x + left.bounds.width;
|
||||
const rightRight = right.bounds.x + right.bounds.width;
|
||||
const horizontallyOverlapping =
|
||||
left.bounds.x < rightRight && right.bounds.x < leftRight;
|
||||
// PDF.js 的文本流保留字符阅读顺序,但不同字体的窄标点可能与
|
||||
// 相邻全角字符发生水平重叠。此时单纯按 x 排序会把闭引号移到
|
||||
// 左括号之后,破坏语义块定位;只对真实重叠片段保留文本流顺序,
|
||||
// 其余片段仍按几何位置排序,兼容 Office 表格等非阅读序文本流。
|
||||
return horizontallyOverlapping
|
||||
? (sourceOrder.get(left) ?? 0) - (sourceOrder.get(right) ?? 0)
|
||||
: left.bounds.x - right.bounds.x;
|
||||
},
|
||||
);
|
||||
const bounds = unionBounds(sortedItems);
|
||||
const normalizedText = joinLineItems(sortedItems);
|
||||
|
||||
@@ -71,6 +71,12 @@ export type EditableParagraphBlockKind =
|
||||
export interface EditableParagraphExpectation {
|
||||
index: number;
|
||||
text: string;
|
||||
mathCharacterIndexes?: number[];
|
||||
hardBreakSegments?: string[];
|
||||
hasInlineCode?: boolean;
|
||||
tableGroupId?: string;
|
||||
tableRowIndex?: number;
|
||||
tableColumnIndex?: number;
|
||||
styleId?: string;
|
||||
role: EditableParagraphRole;
|
||||
blockKind?: EditableParagraphBlockKind;
|
||||
@@ -144,6 +150,7 @@ export interface PdfPageRaster {
|
||||
dpi: number;
|
||||
sha256: string;
|
||||
png: Uint8Array;
|
||||
rgba?: Uint8ClampedArray;
|
||||
}
|
||||
|
||||
export interface PdfPageSnapshot {
|
||||
@@ -287,9 +294,16 @@ export interface PdfRasterDiffMetrics {
|
||||
meanAbsoluteError: number;
|
||||
changedPixelRatio: number;
|
||||
inkIou: number;
|
||||
foregroundInkIou: number;
|
||||
edgeIou: number;
|
||||
backgroundColorDelta: number;
|
||||
foregroundColorDelta: number;
|
||||
baselineForegroundChroma?: number;
|
||||
candidateForegroundChroma?: number;
|
||||
foregroundLuminanceDelta?: number;
|
||||
dominantForegroundColorDelta?: number;
|
||||
chromaticForegroundColorDelta?: number;
|
||||
chromaticPaletteDelta?: number;
|
||||
}
|
||||
|
||||
export interface ComparePageRasterOptions {
|
||||
|
||||
@@ -121,6 +121,37 @@ describe("Office PDF 适配器", () => {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("Office 偶发未生成 PDF 时清理残留并有界重试", async () => {
|
||||
const directory = await mkdtemp(
|
||||
join(tmpdir(), "visual-diff-office-retry-test-"),
|
||||
);
|
||||
const docxPath = join(directory, "fixture.docx");
|
||||
await writeFile(docxPath, "fixture");
|
||||
let attempts = 0;
|
||||
const backend: OfficeAutomationBackend = {
|
||||
probe: async () => true,
|
||||
exportPdf: async (options) => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
await writeFile(options.outputPath, "partial");
|
||||
throw new Error("Office 未生成有效 PDF");
|
||||
}
|
||||
const pdf = createMinimalPdf("retry");
|
||||
await writeFile(options.outputPath, pdf);
|
||||
return { bytes: pdf.byteLength, pageCount: 1 };
|
||||
},
|
||||
};
|
||||
try {
|
||||
const result = await createWordPdfAdapter({ backend }).generate({
|
||||
docxPath,
|
||||
});
|
||||
expect(result.pageCount).toBe(1);
|
||||
expect(attempts).toBe(2);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("PDF 适配器视觉编排", () => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -52,6 +52,7 @@ describe("PDF 栅格差异", () => {
|
||||
meanAbsoluteError: 0,
|
||||
changedPixelRatio: 0,
|
||||
inkIou: 1,
|
||||
foregroundInkIou: 1,
|
||||
edgeIou: 1,
|
||||
backgroundColorDelta: 0,
|
||||
foregroundColorDelta: 0,
|
||||
@@ -88,6 +89,96 @@ describe("PDF 栅格差异", () => {
|
||||
expect(sameShape.metrics.foregroundColorDelta).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("双背景占比翻转时仍独立量化核心字形拓扑", async () => {
|
||||
const inlineCodeRaster = (fullHeightShading: boolean) => {
|
||||
const canvas = createCanvas(216, 25);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, 216, 25);
|
||||
context.fillStyle = "#f3f3f3";
|
||||
context.fillRect(28, fullHeightShading ? 0 : 5, 120, fullHeightShading ? 25 : 16);
|
||||
context.fillStyle = "#333333";
|
||||
context.fillRect(34, 10, 80, 4);
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx: 216,
|
||||
heightPx: 25,
|
||||
dpi: 144,
|
||||
sha256: createHash("sha256").update(png).digest("hex"),
|
||||
png,
|
||||
} satisfies PdfPageRaster;
|
||||
};
|
||||
const result = await comparePageRasters(
|
||||
inlineCodeRaster(false),
|
||||
inlineCodeRaster(true),
|
||||
);
|
||||
|
||||
expect(result.metrics.backgroundColorDelta).toBeGreaterThan(10);
|
||||
expect(result.metrics.foregroundInkIou).toBe(1);
|
||||
});
|
||||
|
||||
it("渐变背景不会把深色文字误判为背景", async () => {
|
||||
const gradientRaster = (gradient: boolean) => {
|
||||
const canvas = createCanvas(160, 40);
|
||||
const context = canvas.getContext("2d");
|
||||
if (gradient) {
|
||||
const fill = context.createLinearGradient(0, 0, 160, 0);
|
||||
fill.addColorStop(0, "#edf5fa");
|
||||
fill.addColorStop(1, "#ffffff");
|
||||
context.fillStyle = fill;
|
||||
} else {
|
||||
context.fillStyle = "#f6fafc";
|
||||
}
|
||||
context.fillRect(0, 0, 160, 40);
|
||||
context.fillStyle = "#0b3557";
|
||||
context.fillRect(4, 4, 148, 32);
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx: 160,
|
||||
heightPx: 40,
|
||||
dpi: 144,
|
||||
sha256: createHash("sha256").update(png).digest("hex"),
|
||||
png,
|
||||
} satisfies PdfPageRaster;
|
||||
};
|
||||
|
||||
const result = await comparePageRasters(
|
||||
gradientRaster(true),
|
||||
gradientRaster(false),
|
||||
);
|
||||
expect(result.metrics.foregroundColorDelta).toBeLessThan(1);
|
||||
expect(result.metrics.backgroundColorDelta).toBeLessThan(8);
|
||||
expect(result.metrics.inkIou).toBeGreaterThan(0.95);
|
||||
});
|
||||
|
||||
it("裁剪跨过深色盒边界时不会把外部白色误判为盒背景", async () => {
|
||||
const boxedRaster = (exposeOutside: boolean) => {
|
||||
const canvas = createCanvas(80, 40);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, 80, 40);
|
||||
context.fillStyle = "#464646";
|
||||
context.fillRect(0, 0, exposeOutside ? 68 : 80, exposeOutside ? 32 : 40);
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(18, 10, 32, 16);
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx: 80,
|
||||
heightPx: 40,
|
||||
dpi: 144,
|
||||
sha256: createHash("sha256").update(png).digest("hex"),
|
||||
png,
|
||||
} satisfies PdfPageRaster;
|
||||
};
|
||||
|
||||
const result = await comparePageRasters(
|
||||
boxedRaster(true),
|
||||
boxedRaster(false),
|
||||
);
|
||||
expect(result.metrics.backgroundColorDelta).toBeLessThan(1);
|
||||
expect(result.metrics.foregroundColorDelta).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("量化位移并生成稳定叠加图与热力图", async () => {
|
||||
const result = await comparePageRasters(raster(10), raster(20), {
|
||||
spatialTolerancePx: 0,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createPdfVisualDiffReport,
|
||||
getBlockingVisualDiffIssues,
|
||||
isCrossEngineRasterEquivalent,
|
||||
renderPdfVisualDiffHtml,
|
||||
serializePdfVisualDiffJson,
|
||||
type PdfDocumentSnapshot,
|
||||
@@ -84,6 +85,27 @@ function antialiasBlockRaster(edgeColor: string): PdfPageRaster {
|
||||
};
|
||||
}
|
||||
|
||||
function adjacentLineRaster(includePreviousLine: boolean): PdfPageRaster {
|
||||
const canvas = createCanvas(40, 50);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, 40, 50);
|
||||
if (includePreviousLine) {
|
||||
context.fillStyle = "#111111";
|
||||
context.fillRect(8, 12, 20, 3);
|
||||
}
|
||||
context.fillStyle = "#111111";
|
||||
context.fillRect(8, 20, 20, 4);
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx: 40,
|
||||
heightPx: 50,
|
||||
dpi: 144,
|
||||
sha256: createHash("sha256").update(png).digest("hex"),
|
||||
png,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(label: string, pageRaster: PdfPageRaster): PdfDocumentSnapshot {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
@@ -561,6 +583,58 @@ describe("PDF 视觉差异报告", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("正文页数不同时分别验证未配对页面自身的页码语义", async () => {
|
||||
const pageNumber = (text: string): PdfTextLineSnapshot => ({
|
||||
text,
|
||||
normalizedText: text,
|
||||
bounds: { x: 9, y: 22, width: 2, height: 1 },
|
||||
baselineY: 23,
|
||||
role: "page-number",
|
||||
items: [],
|
||||
});
|
||||
const baseline = flowSnapshot(
|
||||
"baseline",
|
||||
[
|
||||
[contentLine("甲", 8), pageNumber("1 / 2")],
|
||||
[contentLine("乙丙", 8), pageNumber("2 / 2")],
|
||||
],
|
||||
"#111111",
|
||||
);
|
||||
const candidate = flowSnapshot(
|
||||
"candidate",
|
||||
[[
|
||||
contentLine("甲", 8),
|
||||
contentLine("乙丙", 12),
|
||||
pageNumber("1 / 1"),
|
||||
]],
|
||||
"#cc0000",
|
||||
);
|
||||
const pageSemantics = (count: number) => bodySemantics(count).map(
|
||||
(item, index) => ({
|
||||
...item,
|
||||
footerVisible: true,
|
||||
footerAlignment: "center" as const,
|
||||
pageNumberText: `${index + 1} / ${count}`,
|
||||
}),
|
||||
);
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
expectedEditableText: "甲乙丙",
|
||||
expectedEditableParagraphs: flowParagraphs,
|
||||
baselinePageSemantics: pageSemantics(2),
|
||||
candidatePageSemantics: pageSemantics(1),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("warning");
|
||||
expect(report.basic.bodyFlow).toMatchObject({
|
||||
legal: true,
|
||||
baselineBodyPageCount: 2,
|
||||
candidateBodyPageCount: 1,
|
||||
});
|
||||
expect(report.issues.map((issue) => issue.code)).toContain(
|
||||
"BODY_FLOW_PAGE_COUNT_DIFFERENCE",
|
||||
);
|
||||
});
|
||||
|
||||
it("新增正文页缺少预期页码时不得认定为合法分页流动", async () => {
|
||||
const pageNumber = (text: string): PdfTextLineSnapshot => ({
|
||||
text,
|
||||
@@ -664,6 +738,71 @@ describe("PDF 视觉差异报告", () => {
|
||||
.toMatchObject({ inkIou: 1, edgeIou: 1 });
|
||||
});
|
||||
|
||||
it("局部视觉裁剪不得把目标行上方的相邻行墨迹纳入比较", async () => {
|
||||
const targetLine = contentLine("乙", 10);
|
||||
targetLine.bounds = { x: 4, y: 10, width: 10, height: 2 };
|
||||
const baseline = flowSnapshot(
|
||||
"baseline",
|
||||
[[targetLine]],
|
||||
"#111111",
|
||||
);
|
||||
const candidate = flowSnapshot(
|
||||
"candidate",
|
||||
[[targetLine]],
|
||||
"#111111",
|
||||
);
|
||||
baseline.pages[0]!.raster = adjacentLineRaster(true);
|
||||
candidate.pages[0]!.raster = adjacentLineRaster(false);
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
expectedEditableText: "乙",
|
||||
expectedEditableParagraphs: [{
|
||||
index: 0,
|
||||
text: "乙",
|
||||
role: "body",
|
||||
section: "body",
|
||||
blockKind: "table-cell",
|
||||
}],
|
||||
pageSemantics: bodySemantics(1),
|
||||
});
|
||||
|
||||
expect(report.issues.map((issue) => issue.code)).not.toContain(
|
||||
"SEMANTIC_BLOCK_RASTER_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("仅对带行内代码结构语义的混排允许 WPS 底纹栅格混合差异", () => {
|
||||
const metrics = {
|
||||
baselineWidthPx: 236,
|
||||
baselineHeightPx: 24,
|
||||
candidateWidthPx: 233,
|
||||
candidateHeightPx: 26,
|
||||
comparedWidthPx: 236,
|
||||
comparedHeightPx: 26,
|
||||
dimensionsMatch: false,
|
||||
geometryNormalized: true,
|
||||
spatialTolerancePx: 4,
|
||||
meanAbsoluteError: 22.1,
|
||||
changedPixelRatio: 0.449,
|
||||
inkIou: 0.908,
|
||||
edgeIou: 1,
|
||||
backgroundColorDelta: 11.38,
|
||||
foregroundColorDelta: 2.6,
|
||||
};
|
||||
|
||||
expect(isCrossEngineRasterEquivalent(
|
||||
metrics,
|
||||
false,
|
||||
"paragraph",
|
||||
true,
|
||||
)).toBe(true);
|
||||
expect(isCrossEngineRasterEquivalent(
|
||||
metrics,
|
||||
false,
|
||||
"paragraph",
|
||||
false,
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
it("缺少语义块证据时不得把正文整页栅格自动降级", async () => {
|
||||
const report = await createPdfVisualDiffReport(
|
||||
snapshot("baseline", raster("#111111")),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createCanvas } from "@napi-rs/canvas";
|
||||
|
||||
import {
|
||||
comparePdfSemanticBlockVisuals,
|
||||
isCrossEngineRasterEquivalent,
|
||||
type PdfRasterMetrics,
|
||||
} from "../src/index.js";
|
||||
@@ -28,7 +30,289 @@ function metrics(
|
||||
};
|
||||
}
|
||||
|
||||
function whitePageRaster() {
|
||||
const canvas = createCanvas(400, 400);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#fff";
|
||||
context.fillRect(0, 0, 400, 400);
|
||||
context.fillStyle = "#111";
|
||||
context.fillRect(40, 40, 120, 12);
|
||||
context.fillRect(40, 80, 120, 12);
|
||||
const rgba = context.getImageData(0, 0, 400, 400).data;
|
||||
return {
|
||||
widthPx: 400,
|
||||
heightPx: 400,
|
||||
dpi: 72,
|
||||
sha256: "test",
|
||||
png: Uint8Array.from(canvas.toBuffer("image/png")),
|
||||
rgba,
|
||||
};
|
||||
}
|
||||
|
||||
function blankPageRaster() {
|
||||
const canvas = createCanvas(400, 400);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#fff";
|
||||
context.fillRect(0, 0, 400, 400);
|
||||
const rgba = context.getImageData(0, 0, 400, 400).data;
|
||||
return {
|
||||
widthPx: 400,
|
||||
heightPx: 400,
|
||||
dpi: 72,
|
||||
sha256: "blank",
|
||||
png: Uint8Array.from(canvas.toBuffer("image/png")),
|
||||
rgba,
|
||||
};
|
||||
}
|
||||
|
||||
describe("跨引擎字形栅格等价", () => {
|
||||
it("缺少局部视觉行时必须阻断而不能静默通过", async () => {
|
||||
const [comparison] = await comparePdfSemanticBlockVisuals(
|
||||
{ pages: [] } as never,
|
||||
{ pages: [] } as never,
|
||||
[{
|
||||
expectation: { index: 39, section: "body", blockKind: "table-cell" },
|
||||
baseline: { matched: true, visualLines: [], lineTexts: [] },
|
||||
candidate: { matched: true, visualLines: [], lineTexts: [] },
|
||||
}] as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(comparison?.status).toBe("failed");
|
||||
expect(comparison?.issues).toEqual([
|
||||
expect.objectContaining({
|
||||
code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE",
|
||||
severity: "failure",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("跨引擎换行数不同时按字符区间配对视觉行而不是返回空观测", async () => {
|
||||
const page = {
|
||||
pageNumber: 1,
|
||||
widthPt: 400,
|
||||
heightPt: 400,
|
||||
lines: [],
|
||||
raster: whitePageRaster(),
|
||||
};
|
||||
const visualLine = (y: number) => ({
|
||||
pageNumber: 1,
|
||||
bounds: { x: 40, y, width: 120, height: 12 },
|
||||
fontFamilies: ["Test Sans"],
|
||||
});
|
||||
const [comparison] = await comparePdfSemanticBlockVisuals(
|
||||
{ pages: [page] } as never,
|
||||
{ pages: [page] } as never,
|
||||
[{
|
||||
expectation: {
|
||||
index: 7,
|
||||
section: "body",
|
||||
blockKind: "table-cell",
|
||||
},
|
||||
baseline: {
|
||||
matched: true,
|
||||
visualLines: [visualLine(40), visualLine(80)],
|
||||
lineTexts: ["甲乙", "丙丁"],
|
||||
},
|
||||
candidate: {
|
||||
matched: true,
|
||||
visualLines: [visualLine(40)],
|
||||
lineTexts: ["甲乙丙丁"],
|
||||
},
|
||||
}] as never,
|
||||
{
|
||||
pixelDifferenceThreshold: 8,
|
||||
spatialTolerancePx: 4,
|
||||
minInkIou: 0.75,
|
||||
minEdgeIou: 0.75,
|
||||
maxBackgroundColorDelta: 16,
|
||||
maxForegroundColorDelta: 16,
|
||||
maxMeanAbsoluteError: 20,
|
||||
maxChangedPixelRatio: 0.35,
|
||||
minAntialiasEquivalentInkIou: 0.9,
|
||||
minAntialiasEquivalentEdgeIou: 0.9,
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(comparison?.status).toBe("warning");
|
||||
expect(comparison?.lines).toHaveLength(2);
|
||||
expect(comparison?.issues).not.toContainEqual(
|
||||
expect.objectContaining({ code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE" }),
|
||||
);
|
||||
expect(comparison?.lines.every((line) =>
|
||||
line.issues.some((issue) => issue.details?.textReflow === true)
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it("PDF 文本提取仅遗漏长文本末尾单字时仍比较已有视觉行", async () => {
|
||||
const page = {
|
||||
pageNumber: 1,
|
||||
widthPt: 400,
|
||||
heightPt: 400,
|
||||
lines: [],
|
||||
raster: blankPageRaster(),
|
||||
};
|
||||
const visualLine = (y: number) => ({
|
||||
pageNumber: 1,
|
||||
bounds: { x: 40, y, width: 108, height: 12 },
|
||||
fontFamilies: ["Test Sans"],
|
||||
});
|
||||
const [comparison] = await comparePdfSemanticBlockVisuals(
|
||||
{ pages: [page] } as never,
|
||||
{ pages: [page] } as never,
|
||||
[{
|
||||
expectation: {
|
||||
index: 118,
|
||||
section: "body",
|
||||
blockKind: "table-header",
|
||||
},
|
||||
baseline: {
|
||||
matched: true,
|
||||
matchedCharacterCount: 10,
|
||||
expectedCharacterCount: 10,
|
||||
visualLines: [visualLine(40), visualLine(70)],
|
||||
lineTexts: ["高血压预测模型目标", "值"],
|
||||
},
|
||||
candidate: {
|
||||
matched: false,
|
||||
matchedCharacterCount: 9,
|
||||
expectedCharacterCount: 10,
|
||||
visualLines: [visualLine(40)],
|
||||
lineTexts: ["高血压预测模型目标"],
|
||||
},
|
||||
}] as never,
|
||||
{
|
||||
pixelDifferenceThreshold: 8,
|
||||
spatialTolerancePx: 4,
|
||||
minInkIou: 0.75,
|
||||
minEdgeIou: 0.75,
|
||||
maxBackgroundColorDelta: 16,
|
||||
maxForegroundColorDelta: 16,
|
||||
maxMeanAbsoluteError: 20,
|
||||
maxChangedPixelRatio: 0.35,
|
||||
minAntialiasEquivalentInkIou: 0.9,
|
||||
minAntialiasEquivalentEdgeIou: 0.9,
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(comparison?.lines).toHaveLength(1);
|
||||
expect(comparison?.issues).not.toContainEqual(
|
||||
expect.objectContaining({ code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("表格末尾数学字符未进入 PDF 文本层时仍比较已有视觉行", async () => {
|
||||
const page = {
|
||||
pageNumber: 1,
|
||||
widthPt: 400,
|
||||
heightPt: 400,
|
||||
lines: [],
|
||||
raster: blankPageRaster(),
|
||||
};
|
||||
const visualLine = {
|
||||
pageNumber: 1,
|
||||
bounds: { x: 40, y: 40, width: 108, height: 12 },
|
||||
fontFamilies: ["Test Serif"],
|
||||
};
|
||||
const [comparison] = await comparePdfSemanticBlockVisuals(
|
||||
{ pages: [page] } as never,
|
||||
{ pages: [page] } as never,
|
||||
[{
|
||||
expectation: {
|
||||
index: 71,
|
||||
section: "body",
|
||||
blockKind: "table-cell",
|
||||
mathCharacterIndexes: [3, 4, 5, 6, 11, 12, 13],
|
||||
},
|
||||
baseline: {
|
||||
matched: false,
|
||||
matchedCharacterCount: 12,
|
||||
expectedCharacterCount: 14,
|
||||
visualLines: [visualLine],
|
||||
lineTexts: ["收缩压≥140或舒张压≥"],
|
||||
},
|
||||
candidate: {
|
||||
matched: true,
|
||||
matchedCharacterCount: 14,
|
||||
expectedCharacterCount: 14,
|
||||
visualLines: [visualLine],
|
||||
lineTexts: ["收缩压≥140或舒张压≥90"],
|
||||
},
|
||||
}] as never,
|
||||
{
|
||||
pixelDifferenceThreshold: 8,
|
||||
spatialTolerancePx: 4,
|
||||
minInkIou: 0.75,
|
||||
minEdgeIou: 0.75,
|
||||
maxBackgroundColorDelta: 16,
|
||||
maxForegroundColorDelta: 16,
|
||||
maxMeanAbsoluteError: 20,
|
||||
maxChangedPixelRatio: 0.35,
|
||||
minAntialiasEquivalentInkIou: 0.9,
|
||||
minAntialiasEquivalentEdgeIou: 0.9,
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(comparison?.lines).toHaveLength(1);
|
||||
expect(comparison?.issues).not.toContainEqual(
|
||||
expect.objectContaining({ code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("视觉行数量相同但字符分段不同时按重叠字符区间比较", async () => {
|
||||
const page = {
|
||||
pageNumber: 1,
|
||||
widthPt: 400,
|
||||
heightPt: 400,
|
||||
lines: [],
|
||||
raster: blankPageRaster(),
|
||||
};
|
||||
const visualLine = (y: number, width: number) => ({
|
||||
pageNumber: 1,
|
||||
bounds: { x: 40, y, width, height: 12 },
|
||||
fontFamilies: ["Test Sans"],
|
||||
});
|
||||
const [comparison] = await comparePdfSemanticBlockVisuals(
|
||||
{ pages: [page] } as never,
|
||||
{ pages: [page] } as never,
|
||||
[{
|
||||
expectation: {
|
||||
index: 117,
|
||||
section: "body",
|
||||
blockKind: "table-header",
|
||||
},
|
||||
baseline: {
|
||||
matched: true,
|
||||
visualLines: [visualLine(40, 20), visualLine(70, 40), visualLine(100, 40)],
|
||||
lineTexts: ["记", "录/", "计算"],
|
||||
},
|
||||
candidate: {
|
||||
matched: true,
|
||||
visualLines: [visualLine(40, 40), visualLine(70, 40), visualLine(100, 20)],
|
||||
lineTexts: ["记录", "/计", "算"],
|
||||
},
|
||||
}] as never,
|
||||
{
|
||||
pixelDifferenceThreshold: 8,
|
||||
spatialTolerancePx: 4,
|
||||
minInkIou: 0.75,
|
||||
minEdgeIou: 0.75,
|
||||
maxBackgroundColorDelta: 16,
|
||||
maxForegroundColorDelta: 16,
|
||||
maxMeanAbsoluteError: 20,
|
||||
maxChangedPixelRatio: 0.35,
|
||||
minAntialiasEquivalentInkIou: 0.9,
|
||||
minAntialiasEquivalentEdgeIou: 0.9,
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(comparison?.status).toBe("warning");
|
||||
expect(comparison?.lines).toHaveLength(5);
|
||||
expect(comparison?.issues).not.toContainEqual(
|
||||
expect.objectContaining({ code: "ELEMENT_COLOR_MISMATCH" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("接受轮廓、颜色和几何一致的灰阶抗锯齿差异", () => {
|
||||
expect(isCrossEngineRasterEquivalent(metrics(), false)).toBe(true);
|
||||
expect(
|
||||
@@ -41,6 +325,71 @@ describe("跨引擎字形栅格等价", () => {
|
||||
false,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 56,
|
||||
candidateWidthPx: 56,
|
||||
baselineHeightPx: 37,
|
||||
candidateHeightPx: 37,
|
||||
comparedWidthPx: 56,
|
||||
comparedHeightPx: 37,
|
||||
inkIou: 1,
|
||||
edgeIou: 1,
|
||||
foregroundColorDelta: 17,
|
||||
baselineForegroundChroma: 0,
|
||||
candidateForegroundChroma: 0,
|
||||
foregroundLuminanceDelta: 17,
|
||||
}),
|
||||
false,
|
||||
"code-block",
|
||||
),
|
||||
).toBe(true);
|
||||
const neutralSingleStrokeMetrics = metrics({
|
||||
baselineWidthPx: 37,
|
||||
candidateWidthPx: 37,
|
||||
baselineHeightPx: 27,
|
||||
candidateHeightPx: 27,
|
||||
comparedWidthPx: 37,
|
||||
comparedHeightPx: 27,
|
||||
meanAbsoluteError: 12.78,
|
||||
inkIou: 1,
|
||||
foregroundInkIou: 1,
|
||||
edgeIou: 1,
|
||||
backgroundColorDelta: 10.54,
|
||||
foregroundColorDelta: 108.69,
|
||||
baselineForegroundChroma: 10,
|
||||
candidateForegroundChroma: 5,
|
||||
foregroundLuminanceDelta: 109.27,
|
||||
});
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
neutralSingleStrokeMetrics,
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...neutralSingleStrokeMetrics, meanAbsoluteError: 16.01 },
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...neutralSingleStrokeMetrics, candidateForegroundChroma: 12.01 },
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...neutralSingleStrokeMetrics, edgeIou: 0.994 },
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({ inkIou: 1, edgeIou: 0.946 }),
|
||||
@@ -97,6 +446,26 @@ describe("跨引擎字形栅格等价", () => {
|
||||
"list-item",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 262,
|
||||
candidateWidthPx: 272,
|
||||
baselineHeightPx: 30,
|
||||
candidateHeightPx: 31,
|
||||
comparedWidthPx: 262,
|
||||
comparedHeightPx: 30,
|
||||
inkIou: 0.9792,
|
||||
edgeIou: 0.9869,
|
||||
foregroundColorDelta: 4.63,
|
||||
baselineForegroundChroma: 0,
|
||||
candidateForegroundChroma: 0,
|
||||
foregroundLuminanceDelta: 4.63,
|
||||
}),
|
||||
false,
|
||||
"list-item",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
@@ -120,6 +489,147 @@ describe("跨引擎字形栅格等价", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("仅在高彩度字形轮廓与前景主色均一致时接受调色差异", () => {
|
||||
const paletteMetrics = metrics({
|
||||
inkIou: 1,
|
||||
edgeIou: 1,
|
||||
foregroundColorDelta: 35,
|
||||
baselineForegroundChroma: 141,
|
||||
candidateForegroundChroma: 176,
|
||||
dominantForegroundColorDelta: 0.5,
|
||||
});
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(paletteMetrics, false, "table-cell"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...paletteMetrics, dominantForegroundColorDelta: 4.1 },
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...paletteMetrics, edgeIou: 0.994 },
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...paletteMetrics, baselineForegroundChroma: 79 },
|
||||
false,
|
||||
"table-cell",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("多色代码行仅在语法主色和对应墨迹位置均一致时通过", () => {
|
||||
const syntaxMetrics = metrics({
|
||||
baselineWidthPx: 407,
|
||||
candidateWidthPx: 403,
|
||||
baselineHeightPx: 22,
|
||||
candidateHeightPx: 23,
|
||||
inkIou: 0.988,
|
||||
foregroundInkIou: 1,
|
||||
edgeIou: 1,
|
||||
foregroundColorDelta: 92,
|
||||
chromaticPaletteDelta: 0.8,
|
||||
});
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(syntaxMetrics, false, "code-block"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...syntaxMetrics, chromaticPaletteDelta: 2.1 },
|
||||
false,
|
||||
"code-block",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...syntaxMetrics, foregroundInkIou: 0.994 },
|
||||
false,
|
||||
"code-block",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(syntaxMetrics, false, "paragraph"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("仅在行内代码轮廓和底纹严格一致时接受微小前景色差", () => {
|
||||
const inlineCodeMetrics = metrics({
|
||||
baselineWidthPx: 236,
|
||||
candidateWidthPx: 234,
|
||||
baselineHeightPx: 24,
|
||||
candidateHeightPx: 25,
|
||||
comparedWidthPx: 236,
|
||||
comparedHeightPx: 25,
|
||||
inkIou: 0.91759,
|
||||
edgeIou: 1,
|
||||
backgroundColorDelta: 11.317,
|
||||
foregroundColorDelta: 3.158,
|
||||
});
|
||||
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
inlineCodeMetrics,
|
||||
false,
|
||||
"paragraph",
|
||||
true,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...inlineCodeMetrics, foregroundColorDelta: 3.26 },
|
||||
false,
|
||||
"paragraph",
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...inlineCodeMetrics, backgroundColorDelta: 12.01 },
|
||||
false,
|
||||
"paragraph",
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{ ...inlineCodeMetrics, edgeIou: 0.994 },
|
||||
false,
|
||||
"paragraph",
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{
|
||||
...inlineCodeMetrics,
|
||||
inkIou: 0.88,
|
||||
foregroundInkIou: 0.99,
|
||||
},
|
||||
false,
|
||||
"table-cell",
|
||||
true,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
{
|
||||
...inlineCodeMetrics,
|
||||
inkIou: 0.88,
|
||||
foregroundInkIou: 0.97,
|
||||
},
|
||||
false,
|
||||
"table-cell",
|
||||
true,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("拒绝回流、颜色、几何或轮廓变化", () => {
|
||||
expect(isCrossEngineRasterEquivalent(metrics(), true)).toBe(false);
|
||||
expect(
|
||||
@@ -169,6 +679,42 @@ describe("跨引擎字形栅格等价", () => {
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(metrics({ edgeIou: 0.96 }), false),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 262,
|
||||
candidateWidthPx: 272,
|
||||
baselineHeightPx: 30,
|
||||
candidateHeightPx: 31,
|
||||
inkIou: 0.9792,
|
||||
edgeIou: 0.9869,
|
||||
foregroundColorDelta: 4.63,
|
||||
baselineForegroundChroma: 2.01,
|
||||
candidateForegroundChroma: 0,
|
||||
foregroundLuminanceDelta: 4.63,
|
||||
}),
|
||||
false,
|
||||
"list-item",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 262,
|
||||
candidateWidthPx: 272,
|
||||
baselineHeightPx: 30,
|
||||
candidateHeightPx: 31,
|
||||
inkIou: 0.9792,
|
||||
edgeIou: 0.9869,
|
||||
foregroundColorDelta: 5.01,
|
||||
baselineForegroundChroma: 0,
|
||||
candidateForegroundChroma: 0,
|
||||
foregroundLuminanceDelta: 5.01,
|
||||
}),
|
||||
false,
|
||||
"list-item",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
|
||||
@@ -40,9 +40,88 @@ describe("PDF 文本行聚合", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("合并 Office 同一视觉行内小于字体高度两成的基线漂移", () => {
|
||||
const left = item("源系统", 10, 100, 30, 10);
|
||||
const middle = item("nut_pickup_order.meal_type", 42, 101.8, 140, 10);
|
||||
const right = item("原值", 184, 100, 20, 10);
|
||||
const lines = aggregatePdfTextLines([left, middle, right], 842);
|
||||
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]?.normalizedText).toBe(
|
||||
"源系统nut_pickup_order.meal_type原值",
|
||||
);
|
||||
});
|
||||
|
||||
it("将同行公式的上标片段按横坐标并回正文", () => {
|
||||
const lines = aggregatePdfTextLines(
|
||||
[
|
||||
item("减因子", 10, 100, 42, 12),
|
||||
item("e", 53, 97, 8, 15),
|
||||
item("−λΔt", 62, 96, 24, 10),
|
||||
item("的动态权重", 87, 100, 70, 12),
|
||||
],
|
||||
842,
|
||||
);
|
||||
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]?.normalizedText).toBe("减因子e−λΔt的动态权重");
|
||||
});
|
||||
|
||||
it("将超出正文右缘但紧邻行末的公式片段并回正文", () => {
|
||||
const lines = aggregatePdfTextLines(
|
||||
[
|
||||
item("性、记忆重要程度、时效性衰减权重(须支持基于时间衰减因子 e", 150, 469.5, 346, 12),
|
||||
item("−λΔt", 495.7, 466.1, 26, 10.2),
|
||||
item("下一视觉行", 150, 489.7, 60, 12),
|
||||
],
|
||||
842,
|
||||
);
|
||||
|
||||
expect(lines.map((line) => line.normalizedText)).toEqual([
|
||||
"性、记忆重要程度、时效性衰减权重(须支持基于时间衰减因子 e−λΔt",
|
||||
"下一视觉行",
|
||||
]);
|
||||
});
|
||||
|
||||
it("将字体 ToUnicode 中的传统户部件归一为简体字符", () => {
|
||||
expect(normalizePdfText("戶⼾")).toBe("户户");
|
||||
expect(normalizePdfText("⻅⻆⻓⻔⻚⻛")).toBe("见角长门页风");
|
||||
expect(normalizePdfText("⻅⻆⻓⻔⻚⻛⻝⻣⻋⻬")).toBe(
|
||||
"见角长门页风食骨车齐",
|
||||
);
|
||||
});
|
||||
|
||||
it("统一 Chromium 与 Office PDF 文本层的中英文弯引号", () => {
|
||||
expect(normalizePdfText("“记录”与‘计算’")).toBe('"记录"与\'计算\'');
|
||||
});
|
||||
|
||||
it("保留与全角括号重叠的窄引号文本流顺序", () => {
|
||||
const lines = aggregatePdfTextLines(
|
||||
[
|
||||
item("主观定性判断", 532.3, 151.5, 58.5),
|
||||
item("”", 590.8, 151.5, 3.4),
|
||||
item("(如", 589.3, 151.5, 19.5),
|
||||
item("“", 608.8, 151.5, 3.4),
|
||||
item("高心率占比", 612.2, 151.5, 48.8),
|
||||
],
|
||||
595,
|
||||
);
|
||||
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]?.normalizedText).toBe('主观定性判断"(如"高心率占比');
|
||||
});
|
||||
|
||||
it("忽略 Emoji 文本层可选的变体选择符", () => {
|
||||
expect(normalizePdfText("⚠️ 提示")).toBe("⚠ 提示");
|
||||
});
|
||||
|
||||
it("统一 CJK 字体文本层的 em dash 与 horizontal bar", () => {
|
||||
expect(normalizePdfText("仓库―仓区")).toBe("仓库—仓区");
|
||||
});
|
||||
|
||||
it("统一数字区间中被 Chromium 提取为双连字符的 en dash", () => {
|
||||
expect(normalizePdfText("10% -- 20%")).toBe("10%–20%");
|
||||
expect(normalizePdfText("10% --")).toBe("10%–");
|
||||
expect(normalizePdfText("命令 --flag")).toBe("命令 --flag");
|
||||
});
|
||||
|
||||
it("从可编辑正文契约中移除 Word 自动列表装饰符", () => {
|
||||
|
||||
Reference in New Issue
Block a user