新增通用 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 产物仍为未签名内部发行。
391 lines
12 KiB
TypeScript
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;
|
|
}
|