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 产物仍为未签名内部发行。
This commit is contained in:
SkyJourney
2026-08-04 10:30:44 +08:00
parent b275c671fc
commit 2c5c1bd317
84 changed files with 4404 additions and 344 deletions
@@ -128,14 +128,33 @@ async function padRaster(
function localRasterIssues(
paragraphIndex: number,
lineIndex: number,
blockKind: string | undefined,
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
thresholds: VisualDiffThresholds,
textReflow: boolean,
): VisualDiffIssue[] {
const failed =
metrics.meanAbsoluteError > thresholds.maxMeanAbsoluteError ||
metrics.changedPixelRatio > thresholds.maxChangedPixelRatio ||
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 [];
}
@@ -151,11 +170,86 @@ function localRasterIssues(
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,
@@ -172,6 +266,11 @@ export async function comparePdfSemanticBlockVisuals(
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,
@@ -228,25 +327,44 @@ export async function comparePdfSemanticBlockVisuals(
cropRaster(baselinePage.raster, baselineLine.bounds),
cropRaster(candidatePage.raster, candidateLine.bounds),
]);
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 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({