fix: 重建DOCX视觉一致性门禁
This commit is contained in:
@@ -6,6 +6,7 @@ export * from "./paragraph-layout.js";
|
||||
export * from "./page-semantics.js";
|
||||
export * from "./raster.js";
|
||||
export * from "./report.js";
|
||||
export * from "./semantic-block.js";
|
||||
export * from "./snapshot.js";
|
||||
export * from "./text.js";
|
||||
export * from "./types.js";
|
||||
|
||||
@@ -113,6 +113,17 @@ function observeParagraph(
|
||||
Array.from(lines[index]?.normalizedText ?? "").length ===
|
||||
(matchedByLine.get(index)?.length ?? 0),
|
||||
);
|
||||
const visualLines = matchedLines.map((line) => ({
|
||||
pageNumber: line.pageNumber,
|
||||
bounds: line.line.bounds,
|
||||
fontFamilies: [
|
||||
...new Set(
|
||||
line.line.items.flatMap((item) =>
|
||||
item.fontFamily ? [item.fontFamily] : [],
|
||||
),
|
||||
),
|
||||
].sort(),
|
||||
}));
|
||||
const matched = matchedCharacterCount === expectedCharacters.length;
|
||||
if (matched) {
|
||||
cursor.lineIndex = localCursor.lineIndex;
|
||||
@@ -146,6 +157,7 @@ function observeParagraph(
|
||||
? {}
|
||||
: { averageBaselineGapPt }),
|
||||
exclusiveGeometry,
|
||||
visualLines,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -192,6 +204,8 @@ function compareParagraph(
|
||||
expectation: EditableParagraphExpectation,
|
||||
baseline: PdfParagraphLayoutObservation,
|
||||
candidate: PdfParagraphLayoutObservation,
|
||||
baselineCoverPageCount: number,
|
||||
candidateCoverPageCount: number,
|
||||
): VisualDiffIssue[] {
|
||||
const issues: VisualDiffIssue[] = [];
|
||||
if (!baseline.matched || !candidate.matched) {
|
||||
@@ -299,6 +313,27 @@ function compareParagraph(
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (baselineCoverPageCount > 0 || candidateCoverPageCount > 0) {
|
||||
const baselineOutsideCover = baseline.pageNumbers.every(
|
||||
(pageNumber) => pageNumber > baselineCoverPageCount,
|
||||
);
|
||||
const candidateOutsideCover = candidate.pageNumbers.every(
|
||||
(pageNumber) => pageNumber > candidateCoverPageCount,
|
||||
);
|
||||
if (!baselineOutsideCover || !candidateOutsideCover) {
|
||||
issues.push({
|
||||
code: "COVER_LAYOUT_MISMATCH",
|
||||
severity: "failure",
|
||||
message: `第 ${expectation.index + 1} 个正文段落回流到独立封面页`,
|
||||
details: {
|
||||
paragraphIndex: expectation.index,
|
||||
baselinePages: baseline.pageNumbers.join(","),
|
||||
candidatePages: candidate.pageNumbers.join(","),
|
||||
baselineCoverPageCount,
|
||||
candidateCoverPageCount,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (baseline.exclusiveGeometry && candidate.exclusiveGeometry) {
|
||||
@@ -373,6 +408,12 @@ export function comparePdfEditableParagraphLayouts(
|
||||
const candidateCharacters = candidateLines.map((line) =>
|
||||
Array.from(line.normalizedText),
|
||||
);
|
||||
const baselineCoverPageCount = baselinePageSemantics.filter(
|
||||
(page) => page.kind === "cover",
|
||||
).length;
|
||||
const candidateCoverPageCount = candidatePageSemantics.filter(
|
||||
(page) => page.kind === "cover",
|
||||
).length;
|
||||
const baselineCursor: MatchCursor = { lineIndex: 0, characterIndex: 0 };
|
||||
const candidateCursor: MatchCursor = { lineIndex: 0, characterIndex: 0 };
|
||||
|
||||
@@ -393,6 +434,8 @@ export function comparePdfEditableParagraphLayouts(
|
||||
expectation,
|
||||
baselineObservation,
|
||||
candidateObservation,
|
||||
baselineCoverPageCount,
|
||||
candidateCoverPageCount,
|
||||
);
|
||||
return {
|
||||
expectation,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { comparePdfSnapshotsBasic, DEFAULT_VISUAL_DIFF_THRESHOLDS } from "./compare.js";
|
||||
import { comparePageRasters } from "./raster.js";
|
||||
import { comparePdfPageSemantics } from "./page-semantics.js";
|
||||
import { comparePdfSemanticBlockVisuals } from "./semantic-block.js";
|
||||
import type {
|
||||
CreatePdfVisualDiffOptions,
|
||||
PdfDocumentSnapshot,
|
||||
@@ -21,6 +22,12 @@ function resolveStatus(
|
||||
return issues.length > 0 ? "warning" : "passed";
|
||||
}
|
||||
|
||||
export function getBlockingVisualDiffIssues(
|
||||
report: Pick<PdfVisualDiffReport, "issues">,
|
||||
): VisualDiffIssue[] {
|
||||
return report.issues.filter((issue) => issue.severity === "failure");
|
||||
}
|
||||
|
||||
function rasterIssues(
|
||||
pair: Extract<PdfPagePair, { status: "paired" }>,
|
||||
metrics: NonNullable<PdfVisualPageComparison["metrics"]>,
|
||||
@@ -91,7 +98,7 @@ async function compareVisualPage(
|
||||
baseline: PdfDocumentSnapshot,
|
||||
candidate: PdfDocumentSnapshot,
|
||||
thresholds: VisualDiffThresholds,
|
||||
rasterComparisonMode: "strict" | "body-flow" = "strict",
|
||||
rasterComparisonMode: "strict" | "body-flow" | "body-block" = "strict",
|
||||
): Promise<PdfVisualPageComparison> {
|
||||
if (pair.status !== "paired") {
|
||||
const page =
|
||||
@@ -101,7 +108,7 @@ async function compareVisualPage(
|
||||
return {
|
||||
pair,
|
||||
rasterComparisonMode,
|
||||
status: rasterComparisonMode === "body-flow" ? "warning" : "failed",
|
||||
status: rasterComparisonMode === "strict" ? "failed" : "warning",
|
||||
...(page?.raster
|
||||
? {
|
||||
unpairedPng: page.raster.png,
|
||||
@@ -109,12 +116,18 @@ async function compareVisualPage(
|
||||
}
|
||||
: {}),
|
||||
issues:
|
||||
rasterComparisonMode === "body-flow"
|
||||
rasterComparisonMode !== "strict"
|
||||
? [
|
||||
{
|
||||
code: "BODY_FLOW_RASTER_DIFFERENCE",
|
||||
code:
|
||||
rasterComparisonMode === "body-flow"
|
||||
? "BODY_FLOW_RASTER_DIFFERENCE"
|
||||
: "BODY_PAGE_RASTER_DIFFERENCE",
|
||||
severity: "warning",
|
||||
message: "正文自然分页产生未配对物理页,保留页面快照供审查",
|
||||
message:
|
||||
rasterComparisonMode === "body-flow"
|
||||
? "正文自然分页产生未配对物理页,保留页面快照供审查"
|
||||
: "正文物理页未配对,整页栅格仅作为诊断,最终由语义块门禁判定",
|
||||
...(pair.status === "baseline-only"
|
||||
? { baselinePageNumber: pair.baselinePageNumber }
|
||||
: { candidatePageNumber: pair.candidatePageNumber }),
|
||||
@@ -128,14 +141,15 @@ async function compareVisualPage(
|
||||
if (!baselinePage?.raster || !candidatePage?.raster) {
|
||||
const issue: VisualDiffIssue = {
|
||||
code: "RASTER_MISSING",
|
||||
severity: "failure",
|
||||
severity: rasterComparisonMode === "strict" ? "failure" : "warning",
|
||||
message: `第 ${pair.baselinePageNumber} 页缺少栅格快照`,
|
||||
baselinePageNumber: pair.baselinePageNumber,
|
||||
candidatePageNumber: pair.candidatePageNumber,
|
||||
};
|
||||
return {
|
||||
pair,
|
||||
status: "failed",
|
||||
rasterComparisonMode,
|
||||
status: rasterComparisonMode === "strict" ? "failed" : "warning",
|
||||
issues: [issue],
|
||||
};
|
||||
}
|
||||
@@ -161,12 +175,18 @@ async function compareVisualPage(
|
||||
);
|
||||
const strictIssues = rasterIssues(pair, result.metrics, thresholds);
|
||||
const issues =
|
||||
rasterComparisonMode === "body-flow" && strictIssues.length > 0
|
||||
rasterComparisonMode !== "strict" && strictIssues.length > 0
|
||||
? [
|
||||
{
|
||||
code: "BODY_FLOW_RASTER_DIFFERENCE" as const,
|
||||
code:
|
||||
rasterComparisonMode === "body-flow"
|
||||
? ("BODY_FLOW_RASTER_DIFFERENCE" as const)
|
||||
: ("BODY_PAGE_RASTER_DIFFERENCE" as const),
|
||||
severity: "warning" as const,
|
||||
message: `第 ${pair.baselinePageNumber} 页栅格差异来自已验证的正文自然分页流动`,
|
||||
message:
|
||||
rasterComparisonMode === "body-flow"
|
||||
? `第 ${pair.baselinePageNumber} 页栅格差异来自已验证的正文自然分页流动`
|
||||
: `第 ${pair.baselinePageNumber} 页为正文页,整页栅格差异仅作诊断,最终由语义块门禁判定`,
|
||||
baselinePageNumber: pair.baselinePageNumber,
|
||||
candidatePageNumber: pair.candidatePageNumber,
|
||||
details: {
|
||||
@@ -218,6 +238,26 @@ export async function createPdfVisualDiffReport(
|
||||
? {}
|
||||
: { expectedEditableParagraphs: options.expectedEditableParagraphs }),
|
||||
});
|
||||
if (basic.paragraphLayouts) {
|
||||
const semanticBlockVisuals = await comparePdfSemanticBlockVisuals(
|
||||
baseline,
|
||||
candidate,
|
||||
basic.paragraphLayouts,
|
||||
thresholds,
|
||||
);
|
||||
basic.semanticBlockVisuals = semanticBlockVisuals;
|
||||
basic.issues.push(
|
||||
...semanticBlockVisuals.flatMap((comparison) => comparison.issues),
|
||||
);
|
||||
basic.status = resolveStatus(basic.issues);
|
||||
if (basic.bodyFlow) {
|
||||
basic.bodyFlow.legal =
|
||||
basic.bodyFlow.legal &&
|
||||
semanticBlockVisuals.every(
|
||||
(comparison) => comparison.status !== "failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
const pages: PdfVisualPageComparison[] = [];
|
||||
for (const pair of basic.pagePairs) {
|
||||
const baselineSemanticExpectation =
|
||||
@@ -244,12 +284,21 @@ export async function createPdfVisualDiffReport(
|
||||
pair.candidatePageNumber,
|
||||
))),
|
||||
);
|
||||
const isSemanticBodyPage =
|
||||
baselineSemanticExpectation?.kind !== "cover" &&
|
||||
candidateSemanticExpectation?.kind !== "cover" &&
|
||||
Boolean(baselineSemanticExpectation || candidateSemanticExpectation) &&
|
||||
(basic.semanticBlockVisuals?.length ?? 0) > 0;
|
||||
const page = await compareVisualPage(
|
||||
pair,
|
||||
baseline,
|
||||
candidate,
|
||||
thresholds,
|
||||
flowAffected ? "body-flow" : "strict",
|
||||
flowAffected
|
||||
? "body-flow"
|
||||
: isSemanticBodyPage
|
||||
? "body-block"
|
||||
: "strict",
|
||||
);
|
||||
const primarySemanticExpectation =
|
||||
baselineSemanticExpectation ?? candidateSemanticExpectation;
|
||||
@@ -366,6 +415,8 @@ function pageTitle(page: PdfVisualPageComparison): string {
|
||||
const mode =
|
||||
page.rasterComparisonMode === "body-flow"
|
||||
? "(正文流动比较)"
|
||||
: page.rasterComparisonMode === "body-block"
|
||||
? "(正文整页诊断)"
|
||||
: "(严格比较)";
|
||||
if (page.pair.status === "paired") {
|
||||
return `基线第 ${page.pair.baselinePageNumber} 页 ↔ 候选第 ${page.pair.candidatePageNumber} 页${mode}`;
|
||||
@@ -376,6 +427,27 @@ function pageTitle(page: PdfVisualPageComparison): string {
|
||||
return `候选第 ${page.pair.candidatePageNumber} 页为溢出页${mode}`;
|
||||
}
|
||||
|
||||
function renderSemanticBlockVisuals(report: PdfVisualDiffReport): string {
|
||||
const comparisons = report.basic.semanticBlockVisuals;
|
||||
if (!comparisons) {
|
||||
return "";
|
||||
}
|
||||
const failures = comparisons.filter(
|
||||
(comparison) => comparison.status === "failed",
|
||||
);
|
||||
const rows = (failures.length > 0 ? failures : comparisons.slice(0, 8))
|
||||
.map((comparison) => {
|
||||
const expectation = comparison.expectation;
|
||||
return `<tr><td>${expectation.index + 1}</td><td>${escapeHtml(expectation.section)}</td><td>${escapeHtml(expectation.role)}</td><td>${escapeHtml(comparison.baselineFontFamilies.join(", ") || "未识别")}</td><td>${escapeHtml(comparison.candidateFontFamilies.join(", ") || "未识别")}</td><td>${comparison.lines.length}</td><td>${escapeHtml(comparison.issues.map((issue) => issue.code).join(", ") || "通过")}</td></tr>`;
|
||||
})
|
||||
.join("");
|
||||
return `<div class="semantic-block-visuals">
|
||||
<h2>正文语义块局部视觉 <span class="status ${failures.length > 0 ? "failed" : "passed"}">${failures.length > 0 ? "failed" : "passed"}</span></h2>
|
||||
<p>正文语义块 ${comparisons.length} 个,失败 ${failures.length} 个;块按内容顺序匹配,局部栅格参与硬门禁,物理页号不参与。PDF.js 字体分类仅作诊断,真实字体解析由独立字体门禁判定。</p>
|
||||
<table><thead><tr><th>块</th><th>分节</th><th>角色</th><th>基线字体分类</th><th>候选字体分类</th><th>局部行</th><th>结果</th></tr></thead><tbody>${rows}</tbody></table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderParagraphLayouts(report: PdfVisualDiffReport): string {
|
||||
const paragraphs = report.basic.paragraphLayouts;
|
||||
if (!paragraphs) {
|
||||
@@ -451,6 +523,7 @@ export function renderPdfVisualDiffHtml(
|
||||
<p><strong>生成时间:</strong>${escapeHtml(report.generatedAt)} <strong>正文相似度:</strong>${percentage(report.basic.contentSimilarity)}</p>
|
||||
${editableMetrics}
|
||||
${renderParagraphLayouts(report)}
|
||||
${renderSemanticBlockVisuals(report)}
|
||||
<table><thead><tr><th>级别</th><th>代码</th><th>说明</th></tr></thead><tbody>${issueRows}</tbody></table>
|
||||
</section>
|
||||
${pages}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
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,
|
||||
metrics: NonNullable<PdfSemanticBlockVisualLineComparison["metrics"]>,
|
||||
thresholds: VisualDiffThresholds,
|
||||
): VisualDiffIssue[] {
|
||||
const failed =
|
||||
metrics.meanAbsoluteError > thresholds.maxMeanAbsoluteError ||
|
||||
metrics.changedPixelRatio > thresholds.maxChangedPixelRatio ||
|
||||
metrics.inkIou < thresholds.minInkIou ||
|
||||
metrics.edgeIou < thresholds.minEdgeIou;
|
||||
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,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
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 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 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 lineIssues = localRasterIssues(
|
||||
paragraph.expectation.index,
|
||||
lineIndex,
|
||||
result.metrics,
|
||||
thresholds,
|
||||
);
|
||||
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;
|
||||
}
|
||||
@@ -21,6 +21,10 @@ interface PdfJsTextItem {
|
||||
hasEOL: boolean;
|
||||
}
|
||||
|
||||
interface PdfJsTextStyle {
|
||||
fontFamily?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_PDF_SNAPSHOT_LIMITS: PdfSnapshotLimits = {
|
||||
maxPdfBytes: 100 * 1024 * 1024,
|
||||
maxPages: 500,
|
||||
@@ -55,6 +59,7 @@ function isTextItem(item: unknown): item is PdfJsTextItem {
|
||||
function createTextItemSnapshot(
|
||||
item: PdfJsTextItem,
|
||||
pageHeightPt: number,
|
||||
styles: Readonly<Record<string, PdfJsTextStyle>>,
|
||||
): PdfTextItemSnapshot | undefined {
|
||||
const normalizedText = normalizePdfText(item.str);
|
||||
if (!normalizedText) {
|
||||
@@ -72,6 +77,7 @@ function createTextItemSnapshot(
|
||||
0,
|
||||
);
|
||||
const baselineY = pageHeightPt - baselineFromBottom;
|
||||
const fontFamily = styles[item.fontName]?.fontFamily;
|
||||
return {
|
||||
text: item.str,
|
||||
normalizedText,
|
||||
@@ -83,6 +89,7 @@ function createTextItemSnapshot(
|
||||
},
|
||||
baselineY,
|
||||
fontName: item.fontName,
|
||||
...(fontFamily ? { fontFamily } : {}),
|
||||
hasEol: item.hasEOL,
|
||||
};
|
||||
}
|
||||
@@ -138,11 +145,18 @@ export async function createPdfDocumentSnapshot(
|
||||
disableNormalization: false,
|
||||
includeMarkedContent: false,
|
||||
});
|
||||
const styles = textContent.styles as Readonly<
|
||||
Record<string, PdfJsTextStyle>
|
||||
>;
|
||||
const items = textContent.items.flatMap((item) => {
|
||||
if (!isTextItem(item)) {
|
||||
return [];
|
||||
}
|
||||
const snapshot = createTextItemSnapshot(item, pointViewport.height);
|
||||
const snapshot = createTextItemSnapshot(
|
||||
item,
|
||||
pointViewport.height,
|
||||
styles,
|
||||
);
|
||||
return snapshot ? [snapshot] : [];
|
||||
});
|
||||
const lines = aggregatePdfTextLines(items, pointViewport.height);
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface PdfTextItemSnapshot {
|
||||
bounds: PdfPointBounds;
|
||||
baselineY: number;
|
||||
fontName?: string;
|
||||
fontFamily?: string;
|
||||
hasEol: boolean;
|
||||
}
|
||||
|
||||
@@ -68,6 +69,13 @@ export interface PdfParagraphLayoutObservation {
|
||||
averageLineHeightPt?: number;
|
||||
averageBaselineGapPt?: number;
|
||||
exclusiveGeometry: boolean;
|
||||
visualLines: PdfParagraphVisualLineObservation[];
|
||||
}
|
||||
|
||||
export interface PdfParagraphVisualLineObservation {
|
||||
pageNumber: number;
|
||||
bounds: PdfPointBounds;
|
||||
fontFamilies: string[];
|
||||
}
|
||||
|
||||
export interface PdfParagraphLayoutComparison {
|
||||
@@ -78,6 +86,24 @@ export interface PdfParagraphLayoutComparison {
|
||||
issues: VisualDiffIssue[];
|
||||
}
|
||||
|
||||
export interface PdfSemanticBlockVisualLineComparison {
|
||||
lineIndex: number;
|
||||
baselinePageNumber: number;
|
||||
candidatePageNumber: number;
|
||||
metrics?: PdfRasterDiffMetrics;
|
||||
artifacts?: PdfRasterDiffArtifacts;
|
||||
issues: VisualDiffIssue[];
|
||||
}
|
||||
|
||||
export interface PdfSemanticBlockVisualComparison {
|
||||
expectation: EditableParagraphExpectation;
|
||||
status: "passed" | "warning" | "failed";
|
||||
baselineFontFamilies: string[];
|
||||
candidateFontFamilies: string[];
|
||||
lines: PdfSemanticBlockVisualLineComparison[];
|
||||
issues: VisualDiffIssue[];
|
||||
}
|
||||
|
||||
export interface PdfBodyFlowComparison {
|
||||
detected: boolean;
|
||||
legal: boolean;
|
||||
@@ -171,9 +197,12 @@ export type VisualDiffIssueCode =
|
||||
| "BODY_LINE_COUNT_NEAR_BOUNDARY"
|
||||
| "PARAGRAPH_LINE_BREAK_MISMATCH"
|
||||
| "PARAGRAPH_LINE_GEOMETRY_MISMATCH"
|
||||
| "SEMANTIC_BLOCK_VISUAL_UNAVAILABLE"
|
||||
| "SEMANTIC_BLOCK_RASTER_MISMATCH"
|
||||
| "COVER_LAYOUT_MISMATCH"
|
||||
| "BODY_FLOW_PAGE_COUNT_DIFFERENCE"
|
||||
| "BODY_FLOW_RASTER_DIFFERENCE";
|
||||
| "BODY_FLOW_RASTER_DIFFERENCE"
|
||||
| "BODY_PAGE_RASTER_DIFFERENCE";
|
||||
|
||||
export interface VisualDiffIssue {
|
||||
code: VisualDiffIssueCode;
|
||||
@@ -210,6 +239,7 @@ export interface PdfBasicComparison {
|
||||
candidateEditableSimilarity?: number;
|
||||
candidateEditableExact?: boolean;
|
||||
paragraphLayouts?: PdfParagraphLayoutComparison[];
|
||||
semanticBlockVisuals?: PdfSemanticBlockVisualComparison[];
|
||||
bodyFlow?: PdfBodyFlowComparison;
|
||||
pagePairs: PdfPagePair[];
|
||||
issues: VisualDiffIssue[];
|
||||
@@ -255,7 +285,7 @@ export interface PdfRasterDiffResult {
|
||||
|
||||
export interface PdfVisualPageComparison {
|
||||
pair: PdfPagePair;
|
||||
rasterComparisonMode?: "strict" | "body-flow";
|
||||
rasterComparisonMode?: "strict" | "body-flow" | "body-block";
|
||||
status: "passed" | "warning" | "failed";
|
||||
metrics?: PdfRasterDiffMetrics;
|
||||
artifacts?: PdfRasterDiffArtifacts;
|
||||
|
||||
@@ -166,6 +166,35 @@ describe("PDF 内容感知段落版式门禁", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("拒绝正文内容回流到独立封面页", () => {
|
||||
const baseline = snapshot("baseline", [
|
||||
[line("独立封面", 200)],
|
||||
[line("第一章项目概述", 100)],
|
||||
]);
|
||||
const candidate = snapshot("candidate", [
|
||||
[line("独立封面", 200), line("第一章项目概述", 700)],
|
||||
[],
|
||||
]);
|
||||
const pageSemantics = [
|
||||
{ physicalPageNumber: 1, kind: "cover" as const },
|
||||
{ physicalPageNumber: 2, kind: "body-first" as const },
|
||||
];
|
||||
const [result] = comparePdfEditableParagraphLayouts(
|
||||
baseline,
|
||||
candidate,
|
||||
[expectation("第一章项目概述")],
|
||||
pageSemantics,
|
||||
pageSemantics,
|
||||
);
|
||||
expect(result?.status).toBe("failed");
|
||||
expect(result?.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "COVER_LAYOUT_MISMATCH",
|
||||
severity: "failure",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("忽略 PDF 文本提取器失真的单行宽度但保留诊断值", () => {
|
||||
const baseline = snapshot("baseline", [
|
||||
[line("数据治理平台建设项目", 300, 224)],
|
||||
|
||||
@@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createPdfVisualDiffReport,
|
||||
getBlockingVisualDiffIssues,
|
||||
renderPdfVisualDiffHtml,
|
||||
serializePdfVisualDiffJson,
|
||||
type PdfDocumentSnapshot,
|
||||
@@ -30,6 +31,40 @@ function raster(color: string): PdfPageRaster {
|
||||
};
|
||||
}
|
||||
|
||||
function flowRaster(color: string): PdfPageRaster {
|
||||
const canvas = createCanvas(40, 50);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, 40, 50);
|
||||
context.fillStyle = color;
|
||||
context.fillRect(34, 5, 6, 40);
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx: 40,
|
||||
heightPx: 50,
|
||||
dpi: 144,
|
||||
sha256: createHash("sha256").update(png).digest("hex"),
|
||||
png,
|
||||
};
|
||||
}
|
||||
|
||||
function blockRaster(color: string): PdfPageRaster {
|
||||
const canvas = createCanvas(40, 50);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, 40, 50);
|
||||
context.fillStyle = color;
|
||||
context.fillRect(8, 12, 20, 10);
|
||||
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,
|
||||
@@ -82,7 +117,7 @@ function flowSnapshot(
|
||||
items: [],
|
||||
lines: [...lines],
|
||||
contentText: lines.map((line) => line.normalizedText).join(""),
|
||||
raster: raster(color),
|
||||
raster: flowRaster(color),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -387,7 +422,7 @@ describe("PDF 视觉差异报告", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("整段跨页同时发生换行变化时仍保持严格失败", async () => {
|
||||
it("整段跨页同时发生换行变化时由语义块门禁失败", async () => {
|
||||
const baseline = flowSnapshot(
|
||||
"baseline",
|
||||
[[contentLine("甲", 8)], [contentLine("乙丙", 8)]],
|
||||
@@ -411,7 +446,7 @@ describe("PDF 视觉差异报告", () => {
|
||||
expect(report.status).toBe("failed");
|
||||
expect(report.basic.bodyFlow).toMatchObject({ detected: true, legal: false });
|
||||
expect(report.pages.every(
|
||||
(page) => page.rasterComparisonMode === "strict",
|
||||
(page) => page.rasterComparisonMode === "body-block",
|
||||
)).toBe(true);
|
||||
expect(report.issues.map((issue) => issue.code)).toContain(
|
||||
"TEXT_BLOCK_LINE_COUNT_MISMATCH",
|
||||
@@ -498,4 +533,78 @@ describe("PDF 视觉差异报告", () => {
|
||||
"PAGE_NUMBER_VISIBILITY_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("正文整页差异只作诊断,但语义块局部视觉变化必须失败", async () => {
|
||||
const baseline = flowSnapshot(
|
||||
"baseline",
|
||||
[[contentLine("甲", 8)]],
|
||||
"#111111",
|
||||
);
|
||||
const candidate = flowSnapshot(
|
||||
"candidate",
|
||||
[[contentLine("甲", 8)]],
|
||||
"#111111",
|
||||
);
|
||||
baseline.pages[0]!.raster = blockRaster("#111111");
|
||||
candidate.pages[0]!.raster = blockRaster("#cc0000");
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
expectedEditableText: "甲",
|
||||
expectedEditableParagraphs: [flowParagraphs[0]!],
|
||||
pageSemantics: bodySemantics(1),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("failed");
|
||||
expect(report.pages[0]?.rasterComparisonMode).toBe("body-block");
|
||||
expect(report.issues.map((issue) => issue.code)).toContain(
|
||||
"BODY_PAGE_RASTER_DIFFERENCE",
|
||||
);
|
||||
expect(report.issues.map((issue) => issue.code)).toContain(
|
||||
"SEMANTIC_BLOCK_RASTER_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("缺少语义块证据时不得把正文整页栅格自动降级", async () => {
|
||||
const report = await createPdfVisualDiffReport(
|
||||
snapshot("baseline", raster("#111111")),
|
||||
snapshot("candidate", raster("#cc0000")),
|
||||
{ pageSemantics: bodySemantics(1) },
|
||||
);
|
||||
|
||||
expect(report.status).toBe("failed");
|
||||
expect(report.pages[0]?.rasterComparisonMode).toBe("strict");
|
||||
expect(report.issues.map((issue) => issue.code)).toContain(
|
||||
"CHANGED_PIXEL_RATIO_EXCEEDED",
|
||||
);
|
||||
});
|
||||
|
||||
it("所有 failure 级视觉问题都必须进入发布阻断集合", () => {
|
||||
const failure = {
|
||||
code: "INK_IOU_BELOW_THRESHOLD" as const,
|
||||
severity: "failure" as const,
|
||||
message: "墨迹 IoU 未达到门限",
|
||||
};
|
||||
const warning = {
|
||||
code: "BODY_FLOW_RASTER_DIFFERENCE" as const,
|
||||
severity: "warning" as const,
|
||||
message: "正文自然分页差异",
|
||||
};
|
||||
|
||||
expect(getBlockingVisualDiffIssues({ issues: [failure, warning] })).toEqual([
|
||||
failure,
|
||||
]);
|
||||
});
|
||||
|
||||
it("正文自然分页 warning 不阻断发布", () => {
|
||||
expect(
|
||||
getBlockingVisualDiffIssues({
|
||||
issues: [
|
||||
{
|
||||
code: "BODY_FLOW_RASTER_DIFFERENCE",
|
||||
severity: "warning",
|
||||
message: "正文自然分页差异",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user