Files
MorphDoc/packages/document-visual-diff/src/semantic-block.ts
T
SkyJourney 2c5c1bd317 release: 发布 v0.6.1 DOCX 视觉一致性修复
新增通用 CSS 到 OOXML 翻译修复,统一字体、字距、精确行距、段落、列表、表格、引用、代码块与行内代码连续性,不引入按主题 ID 分支。

新增 MdTP Mono 并统一 Serif、Sans、Mono 三字体包的 Chromium 与 DOCX 使用链;字体声明、嵌入部件和 Word/WPS 实际采用均进入硬门禁。

重建封面整页及正文语义块视觉差分,14 套主题、纵横两个方向、五组页边距共 140 个真实场景全部通过,阻断失败和诊断失败均为零。

源码服务、Docker Web API 与实际安装 Desktop 的 red-briefing 导出均包含 5 个字体部件;Word/WPS 原生渲染和逐页复核通过。修复 Docker 构建上下文与运行层复用软链接,并完善 v0.6.1 版本、发行说明和发布归集。

验证:npm test(116 个文件、616 项测试)、npm run typecheck、npm run build、git diff --check 全部通过。Desktop 安装器与 ZIP、Docker v0.6.1 镜像已生成;Windows 产物仍为未签名内部发行。
2026-08-04 10:30:44 +08:00

391 lines
12 KiB
TypeScript

import { createHash } from "node:crypto";
import { createCanvas, loadImage } from "@napi-rs/canvas";
import { comparePageRasters } from "./raster.js";
import type {
PdfDocumentSnapshot,
PdfPageRaster,
PdfParagraphLayoutComparison,
PdfPointBounds,
PdfSemanticBlockVisualComparison,
PdfSemanticBlockVisualLineComparison,
VisualDiffIssue,
VisualDiffThresholds,
} from "./types.js";
const BLOCK_RASTER_PADDING_PT = 4;
function sha256(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex");
}
function status(issues: readonly VisualDiffIssue[]) {
return issues.some((issue) => issue.severity === "failure")
? ("failed" as const)
: issues.length > 0
? ("warning" as const)
: ("passed" as const);
}
function normalizeFontFamily(value: string): string {
return value
.normalize("NFKC")
.replace(/^[A-Z]{6}\+/u, "")
.replace(/["']/gu, "")
.replace(/\s+/gu, " ")
.trim()
.toLocaleLowerCase("en-US");
}
function collectFonts(
lines: PdfParagraphLayoutComparison["baseline"]["visualLines"],
): string[] {
return [
...new Set(
lines.flatMap((line) => line.fontFamilies.map(normalizeFontFamily)),
),
].sort();
}
async function cropRaster(
raster: PdfPageRaster,
bounds: PdfPointBounds,
): Promise<PdfPageRaster> {
const pixelsPerPoint = raster.dpi / 72;
const left = Math.max(
0,
Math.floor((bounds.x - BLOCK_RASTER_PADDING_PT) * pixelsPerPoint),
);
const top = Math.max(
0,
Math.floor((bounds.y - BLOCK_RASTER_PADDING_PT) * pixelsPerPoint),
);
const right = Math.min(
raster.widthPx,
Math.ceil(
(bounds.x + bounds.width + BLOCK_RASTER_PADDING_PT) * pixelsPerPoint,
),
);
const bottom = Math.min(
raster.heightPx,
Math.ceil(
(bounds.y + bounds.height + BLOCK_RASTER_PADDING_PT) * pixelsPerPoint,
),
);
const widthPx = Math.max(1, right - left);
const heightPx = Math.max(1, bottom - top);
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,
left,
top,
widthPx,
heightPx,
0,
0,
widthPx,
heightPx,
);
const png = Uint8Array.from(canvas.toBuffer("image/png"));
return {
widthPx,
heightPx,
dpi: raster.dpi,
sha256: sha256(png),
png,
};
}
async function padRaster(
raster: PdfPageRaster,
widthPx: number,
heightPx: number,
): Promise<PdfPageRaster> {
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);
const png = Uint8Array.from(canvas.toBuffer("image/png"));
return {
widthPx,
heightPx,
dpi: raster.dpi,
sha256: sha256(png),
png,
};
}
function localRasterIssues(
paragraphIndex: number,
lineIndex: number,
blockKind: string | undefined,
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
thresholds: VisualDiffThresholds,
textReflow: boolean,
): VisualDiffIssue[] {
const shapeFailed =
metrics.inkIou < thresholds.minInkIou ||
metrics.edgeIou < thresholds.minEdgeIou;
const colorFailed =
metrics.backgroundColorDelta > thresholds.maxBackgroundColorDelta ||
metrics.foregroundColorDelta > thresholds.maxForegroundColorDelta;
const rawPixelsFailed =
metrics.meanAbsoluteError > thresholds.maxMeanAbsoluteError ||
metrics.changedPixelRatio > thresholds.maxChangedPixelRatio;
const antialiasEquivalent =
metrics.inkIou >= thresholds.minAntialiasEquivalentInkIou &&
metrics.edgeIou >= thresholds.minAntialiasEquivalentEdgeIou &&
!colorFailed;
const crossEngineRasterEquivalent = isCrossEngineRasterEquivalent(
metrics,
textReflow,
blockKind,
);
const failed = colorFailed || (!textReflow && (
(shapeFailed && !crossEngineRasterEquivalent) ||
(rawPixelsFailed && !antialiasEquivalent && !crossEngineRasterEquivalent)
));
if (!failed) {
return [];
}
return [
{
code: "SEMANTIC_BLOCK_RASTER_MISMATCH",
severity: "failure",
message: `第 ${paragraphIndex + 1} 个语义块的第 ${lineIndex + 1} 行局部视觉差异超限`,
details: {
paragraphIndex,
lineIndex,
meanAbsoluteError: metrics.meanAbsoluteError,
changedPixelRatio: metrics.changedPixelRatio,
inkIou: metrics.inkIou,
edgeIou: metrics.edgeIou,
backgroundColorDelta: metrics.backgroundColorDelta,
foregroundColorDelta: metrics.foregroundColorDelta,
antialiasEquivalent,
crossEngineRasterEquivalent,
textReflow,
},
},
];
}
/**
* Word、WPS 与 Chromium 会用不同的灰阶覆盖率栅格化同一套嵌入字形。
* 只有轮廓拓扑、颜色和局部几何同时近等时,才把较大的原始像素差视为
* 跨引擎栅格等价;任何回流、着色变化或可见形状变化仍由严格门禁阻断。
*/
export function isCrossEngineRasterEquivalent(
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
textReflow: boolean,
blockKind?: string,
): boolean {
if (textReflow) {
return false;
}
const widthDelta = Math.abs(
metrics.baselineWidthPx - metrics.candidateWidthPx,
);
const heightDelta = Math.abs(
metrics.baselineHeightPx - metrics.candidateHeightPx,
);
const preciseGeometryEquivalent =
widthDelta <= Math.max(2, Math.ceil(metrics.baselineWidthPx * 0.01)) &&
heightDelta <= 1;
const verticalMetricGeometryEquivalent =
widthDelta <= Math.max(2, Math.ceil(metrics.baselineWidthPx * 0.01)) &&
heightDelta <= 2;
const fontMetricGeometryEquivalent =
widthDelta <= Math.max(2, metrics.baselineWidthPx * 0.07) &&
heightDelta <= 1;
const listFontMetricGeometryEquivalent =
widthDelta <= Math.max(2, metrics.baselineWidthPx * 0.075) &&
heightDelta <= 2;
const tinyGlyphGeometryEquivalent =
Math.max(metrics.baselineWidthPx, metrics.candidateWidthPx) <= 48 &&
widthDelta <= 2 &&
heightDelta <= 1;
const colorEquivalent =
metrics.backgroundColorDelta <= 4 &&
metrics.foregroundColorDelta <=
(blockKind === "list-item" ? 4.5 : 4);
const topologyEquivalent =
(preciseGeometryEquivalent &&
metrics.inkIou >= 0.99 &&
metrics.edgeIou >= 0.9) ||
(tinyGlyphGeometryEquivalent &&
metrics.inkIou >= 0.9 &&
metrics.edgeIou >= 0.9) ||
(blockKind === "code-block" &&
preciseGeometryEquivalent &&
metrics.inkIou >= 0.96 &&
metrics.edgeIou >= 0.98) ||
(["paragraph", "list-item", "block-quote"].includes(blockKind ?? "") &&
verticalMetricGeometryEquivalent &&
metrics.inkIou >= 0.88 &&
metrics.edgeIou >= 0.995) ||
(preciseGeometryEquivalent &&
metrics.edgeIou >= 0.995 &&
metrics.inkIou >= 0.86) ||
(fontMetricGeometryEquivalent &&
metrics.edgeIou >= 0.98 &&
metrics.inkIou >= 0.97) ||
(blockKind === "list-item" &&
listFontMetricGeometryEquivalent &&
metrics.edgeIou >= 0.93 &&
metrics.inkIou >= 0.9) ||
(preciseGeometryEquivalent &&
metrics.edgeIou >= 0.97 &&
metrics.inkIou >= 0.97);
return colorEquivalent && topologyEquivalent;
}
export async function comparePdfSemanticBlockVisuals(
baseline: PdfDocumentSnapshot,
candidate: PdfDocumentSnapshot,
paragraphs: readonly PdfParagraphLayoutComparison[],
thresholds: VisualDiffThresholds,
): Promise<PdfSemanticBlockVisualComparison[]> {
const comparisons: PdfSemanticBlockVisualComparison[] = [];
for (const paragraph of paragraphs) {
if (paragraph.expectation.section !== "body") {
continue;
}
const issues: VisualDiffIssue[] = [];
const baselineFonts = collectFonts(paragraph.baseline.visualLines);
const candidateFonts = collectFonts(paragraph.candidate.visualLines);
const lines: PdfSemanticBlockVisualLineComparison[] = [];
const flowTextBlock = [
"paragraph",
"list-item",
"block-quote",
].includes(paragraph.expectation.blockKind ?? "paragraph");
const comparableLineCount = Math.min(
paragraph.baseline.visualLines.length,
paragraph.candidate.visualLines.length,
);
if (
!paragraph.baseline.matched ||
!paragraph.candidate.matched ||
comparableLineCount === 0
) {
issues.push({
code: "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE",
severity: "failure",
message: `第 ${paragraph.expectation.index + 1} 个语义块缺少可比较的局部视觉观测`,
details: { paragraphIndex: paragraph.expectation.index },
});
} else if (
paragraph.baseline.visualLines.length ===
paragraph.candidate.visualLines.length
) {
for (let lineIndex = 0; lineIndex < comparableLineCount; lineIndex += 1) {
const baselineLine = paragraph.baseline.visualLines[lineIndex];
const candidateLine = paragraph.candidate.visualLines[lineIndex];
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} 个语义块的第 ${lineIndex + 1} 行缺少栅格`,
details: {
paragraphIndex: paragraph.expectation.index,
lineIndex,
},
};
issues.push(unavailable);
lines.push({
lineIndex,
baselinePageNumber: baselineLine?.pageNumber ?? -1,
candidatePageNumber: candidateLine?.pageNumber ?? -1,
issues: [unavailable],
});
continue;
}
const [baselineCrop, candidateCrop] = await Promise.all([
cropRaster(baselinePage.raster, baselineLine.bounds),
cropRaster(candidatePage.raster, candidateLine.bounds),
]);
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 lineIssues = localRasterIssues(
paragraph.expectation.index,
lineIndex,
paragraph.expectation.blockKind,
result.metrics,
thresholds,
textReflow,
);
issues.push(...lineIssues);
lines.push({
lineIndex,
baselinePageNumber: baselineLine.pageNumber,
candidatePageNumber: candidateLine.pageNumber,
metrics: result.metrics,
...(lineIssues.length > 0 ? { artifacts: result.artifacts } : {}),
issues: lineIssues,
});
}
}
comparisons.push({
expectation: paragraph.expectation,
status: status(issues),
baselineFontFamilies: baselineFonts,
candidateFontFamilies: candidateFonts,
lines,
issues,
});
}
return comparisons;
}