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:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user