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:
@@ -22,8 +22,12 @@ export const DEFAULT_VISUAL_DIFF_THRESHOLDS: VisualDiffThresholds = {
|
||||
spatialTolerancePx: 4,
|
||||
maxMeanAbsoluteError: 8,
|
||||
maxChangedPixelRatio: 0.06,
|
||||
minInkIou: 0.64,
|
||||
minEdgeIou: 0.6,
|
||||
minInkIou: 0.64,
|
||||
minEdgeIou: 0.6,
|
||||
maxBackgroundColorDelta: 12,
|
||||
maxForegroundColorDelta: 12,
|
||||
minAntialiasEquivalentInkIou: 0.98,
|
||||
minAntialiasEquivalentEdgeIou: 0.98,
|
||||
};
|
||||
|
||||
function cappedLevenshteinDistance(
|
||||
@@ -259,6 +263,21 @@ export function comparePdfSnapshotsBasic(
|
||||
const candidateEditableText = hasEditableExpectation
|
||||
? buildPdfEditableContentText(candidate, candidatePageSemantics)
|
||||
: "";
|
||||
const paragraphLayouts = contentOptions.expectedEditableParagraphs
|
||||
? comparePdfEditableParagraphLayouts(
|
||||
baseline,
|
||||
candidate,
|
||||
contentOptions.expectedEditableParagraphs,
|
||||
baselinePageSemantics,
|
||||
candidatePageSemantics,
|
||||
)
|
||||
: undefined;
|
||||
const paragraphContentExact =
|
||||
paragraphLayouts !== undefined &&
|
||||
paragraphLayouts.length === contentOptions.expectedEditableParagraphs?.length &&
|
||||
paragraphLayouts.every(
|
||||
(paragraph) => paragraph.baseline.matched && paragraph.candidate.matched,
|
||||
);
|
||||
const baselineEditableCoverage = hasEditableExpectation
|
||||
? calculateOrderedContentCoverage(expectedEditableText, baselineEditableText)
|
||||
: undefined;
|
||||
@@ -270,7 +289,7 @@ export function comparePdfSnapshotsBasic(
|
||||
)
|
||||
: undefined;
|
||||
const candidateEditableExact = hasEditableExpectation
|
||||
? candidateEditableText === expectedEditableText
|
||||
? candidateEditableText === expectedEditableText || paragraphContentExact
|
||||
: undefined;
|
||||
const contentSimilarity = hasEditableExpectation
|
||||
? Math.min(
|
||||
@@ -302,15 +321,6 @@ export function comparePdfSnapshotsBasic(
|
||||
});
|
||||
}
|
||||
|
||||
const paragraphLayouts = contentOptions.expectedEditableParagraphs
|
||||
? comparePdfEditableParagraphLayouts(
|
||||
baseline,
|
||||
candidate,
|
||||
contentOptions.expectedEditableParagraphs,
|
||||
baselinePageSemantics,
|
||||
candidatePageSemantics,
|
||||
)
|
||||
: undefined;
|
||||
if (paragraphLayouts) {
|
||||
issues.push(...paragraphLayouts.flatMap((paragraph) => paragraph.issues));
|
||||
}
|
||||
@@ -376,7 +386,7 @@ export function comparePdfSnapshotsBasic(
|
||||
const bodyFlowLegal =
|
||||
bodyFlowDetected &&
|
||||
paragraphLayouts !== undefined &&
|
||||
paragraphLayouts.every((paragraph) => paragraph.status === "passed") &&
|
||||
paragraphLayouts.every((paragraph) => paragraph.status !== "failed") &&
|
||||
baselineEditableCoverage === 1 &&
|
||||
candidateEditableExact === true &&
|
||||
baselineCoverPageCount === candidateCoverPageCount &&
|
||||
|
||||
@@ -8,11 +8,13 @@ import type {
|
||||
PdfEditableContentLine,
|
||||
PdfParagraphLayoutComparison,
|
||||
PdfParagraphLayoutObservation,
|
||||
PdfPointBounds,
|
||||
VisualDiffIssue,
|
||||
VisualPageSemanticExpectation,
|
||||
} from "./types.js";
|
||||
|
||||
const LINE_BREAK_CHARACTER_TOLERANCE = 1;
|
||||
const BODY_REFLOW_BREAK_CHARACTER_TOLERANCE = 3;
|
||||
const LINE_X_TOLERANCE_PT = 2;
|
||||
const COVER_LINE_X_TOLERANCE_PT = 12;
|
||||
const LINE_HEIGHT_TOLERANCE_PT = 1;
|
||||
@@ -26,6 +28,17 @@ interface MatchCursor {
|
||||
characterIndex: number;
|
||||
}
|
||||
|
||||
interface MatchedCharacterRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface MatchedCharacterPosition {
|
||||
lineIndex: number;
|
||||
characterIndex: number;
|
||||
character: string;
|
||||
}
|
||||
|
||||
function average(values: readonly number[]): number | undefined {
|
||||
if (values.length === 0) {
|
||||
return undefined;
|
||||
@@ -33,6 +46,113 @@ function average(values: readonly number[]): number | undefined {
|
||||
return values.reduce((sum, value) => sum + value, 0) / values.length;
|
||||
}
|
||||
|
||||
function unionItemBounds(
|
||||
items: readonly { bounds: PdfPointBounds }[],
|
||||
): PdfPointBounds | undefined {
|
||||
if (items.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const left = Math.min(...items.map((item) => item.bounds.x));
|
||||
const top = Math.min(...items.map((item) => item.bounds.y));
|
||||
const right = Math.max(
|
||||
...items.map((item) => item.bounds.x + item.bounds.width),
|
||||
);
|
||||
const bottom = Math.max(
|
||||
...items.map((item) => item.bounds.y + item.bounds.height),
|
||||
);
|
||||
return {
|
||||
x: left,
|
||||
y: top,
|
||||
width: Math.max(0, right - left),
|
||||
height: Math.max(0, bottom - top),
|
||||
};
|
||||
}
|
||||
|
||||
function characterAdvanceWeight(character: string): number {
|
||||
if (/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]$/u.test(character)) {
|
||||
return 1;
|
||||
}
|
||||
if (/^[,。、;:!?()《》【】「」『』〔〕[]{}]$/u.test(character)) {
|
||||
return 1;
|
||||
}
|
||||
if (/^[A-Za-z0-9]$/u.test(character)) {
|
||||
return 0.55;
|
||||
}
|
||||
if (/^[.,:;!'"`·]$/u.test(character)) {
|
||||
return 0.28;
|
||||
}
|
||||
if (/^[-–—/\\]$/u.test(character)) {
|
||||
return 0.4;
|
||||
}
|
||||
return 0.6;
|
||||
}
|
||||
|
||||
function weightedCharacterRatio(
|
||||
characters: readonly string[],
|
||||
offset: number,
|
||||
): number {
|
||||
const weights = characters.map(characterAdvanceWeight);
|
||||
const total = weights.reduce((sum, weight) => sum + weight, 0);
|
||||
if (total <= 0) {
|
||||
return offset / Math.max(1, characters.length);
|
||||
}
|
||||
return weights
|
||||
.slice(0, offset)
|
||||
.reduce((sum, weight) => sum + weight, 0) / total;
|
||||
}
|
||||
|
||||
function matchedLineItems(
|
||||
line: PdfEditableContentLine["line"],
|
||||
normalizedLineText: string,
|
||||
range: MatchedCharacterRange | undefined,
|
||||
) {
|
||||
if (!range || line.items.length === 0) {
|
||||
return { bounds: line.bounds, items: line.items };
|
||||
}
|
||||
const selected = [];
|
||||
let searchFrom = 0;
|
||||
for (const item of line.items) {
|
||||
const text = normalizePdfEditableText(item.normalizedText);
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
const start = normalizedLineText.indexOf(text, searchFrom);
|
||||
if (start < 0) {
|
||||
continue;
|
||||
}
|
||||
const end = start + Array.from(text).length;
|
||||
searchFrom = end;
|
||||
const overlapStart = Math.max(start, range.start);
|
||||
const overlapEnd = Math.min(end, range.end);
|
||||
if (overlapStart >= overlapEnd) {
|
||||
continue;
|
||||
}
|
||||
const characters = Array.from(text);
|
||||
const leftRatio = weightedCharacterRatio(
|
||||
characters,
|
||||
overlapStart - start,
|
||||
);
|
||||
const rightRatio = weightedCharacterRatio(
|
||||
characters,
|
||||
overlapEnd - start,
|
||||
);
|
||||
selected.push({
|
||||
item,
|
||||
bounds: {
|
||||
...item.bounds,
|
||||
x: item.bounds.x + item.bounds.width * leftRatio,
|
||||
width: item.bounds.width * (rightRatio - leftRatio),
|
||||
},
|
||||
});
|
||||
}
|
||||
return {
|
||||
bounds: unionItemBounds(selected) ?? line.bounds,
|
||||
items: selected.length > 0
|
||||
? selected.map((entry) => entry.item)
|
||||
: line.items,
|
||||
};
|
||||
}
|
||||
|
||||
function advanceCursor(
|
||||
cursor: MatchCursor,
|
||||
lineCharacters: readonly string[][],
|
||||
@@ -48,42 +168,88 @@ function advanceCursor(
|
||||
return cursor.lineIndex < lineCharacters.length;
|
||||
}
|
||||
|
||||
function matchContiguousCharacters(
|
||||
start: MatchCursor,
|
||||
expectedCharacters: readonly string[],
|
||||
lineCharacters: readonly string[][],
|
||||
) {
|
||||
const cursor = { ...start };
|
||||
const positions: MatchedCharacterPosition[] = [];
|
||||
for (const expectedCharacter of expectedCharacters) {
|
||||
const actualCharacter =
|
||||
lineCharacters[cursor.lineIndex]?.[cursor.characterIndex];
|
||||
if (actualCharacter !== expectedCharacter) {
|
||||
break;
|
||||
}
|
||||
positions.push({
|
||||
lineIndex: cursor.lineIndex,
|
||||
characterIndex: cursor.characterIndex,
|
||||
character: expectedCharacter,
|
||||
});
|
||||
advanceCursor(cursor, lineCharacters);
|
||||
}
|
||||
return { cursor, positions };
|
||||
}
|
||||
|
||||
function observeParagraph(
|
||||
expectation: EditableParagraphExpectation,
|
||||
lines: readonly PdfEditableContentLine[],
|
||||
lineCharacters: readonly string[][],
|
||||
cursor: MatchCursor,
|
||||
): PdfParagraphLayoutObservation {
|
||||
const localCursor: MatchCursor = { ...cursor };
|
||||
const expectedCharacters = Array.from(
|
||||
normalizePdfEditableText(expectation.text),
|
||||
);
|
||||
const matchedByLine = new Map<number, string[]>();
|
||||
let matchedCharacterCount = 0;
|
||||
|
||||
for (const expectedCharacter of expectedCharacters) {
|
||||
let found = false;
|
||||
while (localCursor.lineIndex < lineCharacters.length) {
|
||||
const actualCharacter =
|
||||
lineCharacters[localCursor.lineIndex]?.[localCursor.characterIndex];
|
||||
if (actualCharacter === expectedCharacter) {
|
||||
const matches = matchedByLine.get(localCursor.lineIndex) ?? [];
|
||||
matches.push(expectedCharacter);
|
||||
matchedByLine.set(localCursor.lineIndex, matches);
|
||||
matchedCharacterCount += 1;
|
||||
advanceCursor(localCursor, lineCharacters);
|
||||
found = true;
|
||||
break;
|
||||
const matchedRanges = new Map<number, MatchedCharacterRange>();
|
||||
let matchedPositions: MatchedCharacterPosition[] = [];
|
||||
let matchedCursor: MatchCursor = { ...cursor };
|
||||
let matched = expectedCharacters.length === 0;
|
||||
const searchCursor: MatchCursor = { ...cursor };
|
||||
while (!matched && searchCursor.lineIndex < lineCharacters.length) {
|
||||
if (
|
||||
lineCharacters[searchCursor.lineIndex]?.[
|
||||
searchCursor.characterIndex
|
||||
] === expectedCharacters[0]
|
||||
) {
|
||||
const attempt = matchContiguousCharacters(
|
||||
searchCursor,
|
||||
expectedCharacters,
|
||||
lineCharacters,
|
||||
);
|
||||
if (attempt.positions.length > matchedPositions.length) {
|
||||
matchedPositions = attempt.positions;
|
||||
}
|
||||
if (!advanceCursor(localCursor, lineCharacters)) {
|
||||
if (attempt.positions.length === expectedCharacters.length) {
|
||||
matched = true;
|
||||
matchedPositions = attempt.positions;
|
||||
matchedCursor = attempt.cursor;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
if (!advanceCursor(searchCursor, lineCharacters)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (const position of matchedPositions) {
|
||||
const matches = matchedByLine.get(position.lineIndex) ?? [];
|
||||
matches.push(position.character);
|
||||
matchedByLine.set(position.lineIndex, matches);
|
||||
const range = matchedRanges.get(position.lineIndex);
|
||||
matchedRanges.set(position.lineIndex, {
|
||||
start: Math.min(
|
||||
range?.start ?? position.characterIndex,
|
||||
position.characterIndex,
|
||||
),
|
||||
end: Math.max(
|
||||
range?.end ?? position.characterIndex + 1,
|
||||
position.characterIndex + 1,
|
||||
),
|
||||
});
|
||||
}
|
||||
const matchedCharacterCount = matchedPositions.length;
|
||||
|
||||
const matchedLineIndexes = [...matchedByLine.keys()];
|
||||
const matchedLines = matchedLineIndexes.flatMap((index) =>
|
||||
lines[index] ? [lines[index]] : [],
|
||||
@@ -113,21 +279,27 @@ 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: [
|
||||
const visualLines = matchedLines.map((line, index) => {
|
||||
const matched = matchedLineItems(
|
||||
line.line,
|
||||
line.normalizedText,
|
||||
matchedRanges.get(matchedLineIndexes[index]!),
|
||||
);
|
||||
return {
|
||||
pageNumber: line.pageNumber,
|
||||
bounds: matched.bounds,
|
||||
fontFamilies: [
|
||||
...new Set(
|
||||
line.line.items.flatMap((item) =>
|
||||
matched.items.flatMap((item) =>
|
||||
item.fontFamily ? [item.fontFamily] : [],
|
||||
),
|
||||
),
|
||||
].sort(),
|
||||
}));
|
||||
const matched = matchedCharacterCount === expectedCharacters.length;
|
||||
};
|
||||
});
|
||||
if (matched) {
|
||||
cursor.lineIndex = localCursor.lineIndex;
|
||||
cursor.characterIndex = localCursor.characterIndex;
|
||||
cursor.lineIndex = matchedCursor.lineIndex;
|
||||
cursor.characterIndex = matchedCursor.characterIndex;
|
||||
}
|
||||
return {
|
||||
matched,
|
||||
@@ -139,15 +311,15 @@ function observeParagraph(
|
||||
lineBreakOffsets,
|
||||
...(firstLine
|
||||
? {
|
||||
firstLineXPt: firstLine.line.bounds.x,
|
||||
firstLineXPt: visualLines[0]!.bounds.x,
|
||||
firstLineBaselineYPt: firstLine.line.baselineY,
|
||||
}
|
||||
: {}),
|
||||
...(average(matchedLines.map((line) => line.line.bounds.width)) === undefined
|
||||
...(average(visualLines.map((line) => line.bounds.width)) === undefined
|
||||
? {}
|
||||
: {
|
||||
maximumLineWidthPt: Math.max(
|
||||
...matchedLines.map((line) => line.line.bounds.width),
|
||||
...visualLines.map((line) => line.bounds.width),
|
||||
),
|
||||
}),
|
||||
...(averageLineHeightPt === undefined
|
||||
@@ -176,6 +348,14 @@ function trailingLineCharacterCount(
|
||||
return Array.from(observation.lineTexts.at(-1) ?? "").length;
|
||||
}
|
||||
|
||||
function allowsBodyTextReflow(expectation: EditableParagraphExpectation) {
|
||||
return expectation.section === "body" && [
|
||||
"paragraph",
|
||||
"list-item",
|
||||
"block-quote",
|
||||
].includes(expectation.blockKind ?? "paragraph");
|
||||
}
|
||||
|
||||
function isBodyNearBoundaryLineCountDifference(
|
||||
expectation: EditableParagraphExpectation,
|
||||
baseline: PdfParagraphLayoutObservation,
|
||||
@@ -271,14 +451,21 @@ function compareParagraph(
|
||||
),
|
||||
);
|
||||
if (maximumBreakDelta > LINE_BREAK_CHARACTER_TOLERANCE) {
|
||||
const toleratedReflow =
|
||||
allowsBodyTextReflow(expectation) &&
|
||||
maximumBreakDelta <= BODY_REFLOW_BREAK_CHARACTER_TOLERANCE;
|
||||
issues.push({
|
||||
code: "PARAGRAPH_LINE_BREAK_MISMATCH",
|
||||
severity: "failure",
|
||||
message: `第 ${expectation.index + 1} 个段落的行内换行边界偏差 ${maximumBreakDelta} 个字符`,
|
||||
severity: toleratedReflow ? "warning" : "failure",
|
||||
message: toleratedReflow
|
||||
? `第 ${expectation.index + 1} 个正文块发生允许的跨引擎行内重排,边界偏差 ${maximumBreakDelta} 个字符`
|
||||
: `第 ${expectation.index + 1} 个段落的行内换行边界偏差 ${maximumBreakDelta} 个字符`,
|
||||
details: {
|
||||
paragraphIndex: expectation.index,
|
||||
maximumBreakDelta,
|
||||
tolerance: LINE_BREAK_CHARACTER_TOLERANCE,
|
||||
reflowTolerance: BODY_REFLOW_BREAK_CHARACTER_TOLERANCE,
|
||||
toleratedReflow,
|
||||
baselineBreaks: baseline.lineBreakOffsets.join(","),
|
||||
candidateBreaks: candidate.lineBreakOffsets.join(","),
|
||||
},
|
||||
|
||||
@@ -16,6 +16,14 @@ interface DecodedRaster {
|
||||
rgba: Uint8ClampedArray;
|
||||
}
|
||||
|
||||
interface RgbColor {
|
||||
red: number;
|
||||
green: number;
|
||||
blue: number;
|
||||
}
|
||||
|
||||
const RASTER_MASK_BORDER_PX = 2;
|
||||
|
||||
function sha256(bytes: Uint8Array): string {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
@@ -48,6 +56,143 @@ function luminance(
|
||||
return (77 * red + 150 * green + 29 * blue) >> 8;
|
||||
}
|
||||
|
||||
function colorDistance(left: RgbColor, right: RgbColor): number {
|
||||
return Math.sqrt(
|
||||
((left.red - right.red) ** 2 +
|
||||
(left.green - right.green) ** 2 +
|
||||
(left.blue - right.blue) ** 2) /
|
||||
3,
|
||||
);
|
||||
}
|
||||
|
||||
function rasterBackgroundColor(
|
||||
rgba: Uint8ClampedArray,
|
||||
_width: number,
|
||||
_height: number,
|
||||
): RgbColor {
|
||||
const buckets = new Map<
|
||||
number,
|
||||
{ count: number; red: number; green: number; blue: number }
|
||||
>();
|
||||
let dominant:
|
||||
| { count: number; red: number; green: number; blue: number }
|
||||
| undefined;
|
||||
for (let offset = 0; offset < rgba.length; offset += 4) {
|
||||
const red = rgba[offset] ?? 255;
|
||||
const green = rgba[offset + 1] ?? 255;
|
||||
const blue = rgba[offset + 2] ?? 255;
|
||||
const bucketKey = (red >> 3) << 10 | (green >> 3) << 5 | (blue >> 3);
|
||||
const bucket = buckets.get(bucketKey) ?? {
|
||||
count: 0,
|
||||
red: 0,
|
||||
green: 0,
|
||||
blue: 0,
|
||||
};
|
||||
bucket.count += 1;
|
||||
bucket.red += red;
|
||||
bucket.green += green;
|
||||
bucket.blue += blue;
|
||||
buckets.set(bucketKey, bucket);
|
||||
if (!dominant || bucket.count > dominant.count) {
|
||||
dominant = bucket;
|
||||
}
|
||||
}
|
||||
return !dominant
|
||||
? { red: 255, green: 255, blue: 255 }
|
||||
: {
|
||||
red: dominant.red / dominant.count,
|
||||
green: dominant.green / dominant.count,
|
||||
blue: dominant.blue / dominant.count,
|
||||
};
|
||||
}
|
||||
|
||||
function rasterForegroundColor(
|
||||
decoded: DecodedRaster,
|
||||
): RgbColor {
|
||||
const background = rasterBackgroundColor(
|
||||
decoded.rgba,
|
||||
decoded.width,
|
||||
decoded.height,
|
||||
);
|
||||
const buckets = new Map<
|
||||
number,
|
||||
{ count: number; red: number; green: number; blue: number }
|
||||
>();
|
||||
for (let offset = 0; offset < decoded.rgba.length; offset += 4) {
|
||||
const color = {
|
||||
red: decoded.rgba[offset] ?? 255,
|
||||
green: decoded.rgba[offset + 1] ?? 255,
|
||||
blue: decoded.rgba[offset + 2] ?? 255,
|
||||
};
|
||||
const distance = colorDistance(color, background);
|
||||
if (distance < 48) {
|
||||
continue;
|
||||
}
|
||||
const bucketKey = (color.red >> 3) << 10 |
|
||||
(color.green >> 3) << 5 |
|
||||
(color.blue >> 3);
|
||||
const bucket = buckets.get(bucketKey) ?? {
|
||||
count: 0,
|
||||
red: 0,
|
||||
green: 0,
|
||||
blue: 0,
|
||||
};
|
||||
bucket.count += 1;
|
||||
bucket.red += color.red;
|
||||
bucket.green += color.green;
|
||||
bucket.blue += color.blue;
|
||||
buckets.set(bucketKey, bucket);
|
||||
}
|
||||
let dominant:
|
||||
| { count: number; red: number; green: number; blue: number }
|
||||
| undefined;
|
||||
let dominantDistance = -1;
|
||||
for (const bucket of buckets.values()) {
|
||||
const averageColor = {
|
||||
red: bucket.red / bucket.count,
|
||||
green: bucket.green / bucket.count,
|
||||
blue: bucket.blue / bucket.count,
|
||||
};
|
||||
const distance = colorDistance(averageColor, background);
|
||||
if (
|
||||
distance > dominantDistance + 1 ||
|
||||
(Math.abs(distance - dominantDistance) <= 1 &&
|
||||
bucket.count > (dominant?.count ?? 0))
|
||||
) {
|
||||
dominant = bucket;
|
||||
dominantDistance = distance;
|
||||
}
|
||||
}
|
||||
return !dominant
|
||||
? background
|
||||
: {
|
||||
red: dominant.red / dominant.count,
|
||||
green: dominant.green / dominant.count,
|
||||
blue: dominant.blue / dominant.count,
|
||||
};
|
||||
}
|
||||
|
||||
function clearMaskBorder(
|
||||
mask: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
borderPx = RASTER_MASK_BORDER_PX,
|
||||
) {
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
for (let x = 0; x < width; x += 1) {
|
||||
if (
|
||||
x < borderPx ||
|
||||
y < borderPx ||
|
||||
x >= width - borderPx ||
|
||||
y >= height - borderPx
|
||||
) {
|
||||
mask[y * width + x] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
function calculateBinaryIou(
|
||||
left: Uint8Array,
|
||||
right: Uint8Array,
|
||||
@@ -292,6 +437,16 @@ export async function comparePageRasters(
|
||||
const pixelDifference = new Uint8Array(pixelCount);
|
||||
const baselineInk = new Uint8Array(pixelCount);
|
||||
const candidateInk = new Uint8Array(pixelCount);
|
||||
const baselineBackground = rasterBackgroundColor(
|
||||
baselineDecoded.rgba,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
const candidateBackground = rasterBackgroundColor(
|
||||
candidateDecoded.rgba,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
let absoluteError = 0;
|
||||
let changedPixels = 0;
|
||||
|
||||
@@ -323,25 +478,43 @@ export async function comparePageRasters(
|
||||
const candidateValue = luminance(candidateDecoded.rgba, offset);
|
||||
baselineGray[pixelIndex] = baselineValue;
|
||||
candidateGray[pixelIndex] = candidateValue;
|
||||
if (baselineValue < 245) {
|
||||
const baselineColor = {
|
||||
red: baselineDecoded.rgba[offset] ?? 255,
|
||||
green: baselineDecoded.rgba[offset + 1] ?? 255,
|
||||
blue: baselineDecoded.rgba[offset + 2] ?? 255,
|
||||
};
|
||||
const candidateColor = {
|
||||
red: candidateDecoded.rgba[offset] ?? 255,
|
||||
green: candidateDecoded.rgba[offset + 1] ?? 255,
|
||||
blue: candidateDecoded.rgba[offset + 2] ?? 255,
|
||||
};
|
||||
if (colorDistance(baselineColor, baselineBackground) >= 16) {
|
||||
baselineInk[pixelIndex] = 1;
|
||||
}
|
||||
if (candidateValue < 245) {
|
||||
if (colorDistance(candidateColor, candidateBackground) >= 16) {
|
||||
candidateInk[pixelIndex] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
const baselineEdges = createEdgeMask(
|
||||
baselineGray,
|
||||
clearMaskBorder(baselineInk, width, height);
|
||||
clearMaskBorder(candidateInk, width, height);
|
||||
const baselineEdges = clearMaskBorder(
|
||||
createEdgeMask(baselineGray, width, height, 40),
|
||||
width,
|
||||
height,
|
||||
40,
|
||||
);
|
||||
const candidateEdges = createEdgeMask(
|
||||
candidateGray,
|
||||
const candidateEdges = clearMaskBorder(
|
||||
createEdgeMask(candidateGray, width, height, 40),
|
||||
width,
|
||||
height,
|
||||
40,
|
||||
);
|
||||
const foregroundColorDelta = colorDistance(
|
||||
rasterForegroundColor(baselineDecoded),
|
||||
rasterForegroundColor(candidateDecoded),
|
||||
);
|
||||
const backgroundColorDelta = colorDistance(
|
||||
baselineBackground,
|
||||
candidateBackground,
|
||||
);
|
||||
const metrics: PdfRasterDiffMetrics = {
|
||||
baselineWidthPx: baseline.widthPx,
|
||||
@@ -371,6 +544,8 @@ export async function comparePageRasters(
|
||||
height,
|
||||
spatialTolerancePx,
|
||||
),
|
||||
backgroundColorDelta,
|
||||
foregroundColorDelta,
|
||||
};
|
||||
return {
|
||||
metrics,
|
||||
|
||||
@@ -460,7 +460,7 @@ function renderParagraphLayouts(report: PdfVisualDiffReport): string {
|
||||
const rows = (failures.length > 0 ? failures : paragraphs.slice(0, 8))
|
||||
.map((paragraph) => {
|
||||
const expectation = paragraph.expectation;
|
||||
return `<tr><td>${expectation.index + 1}</td><td>${escapeHtml(expectation.section)}</td><td>${escapeHtml(expectation.role)}</td><td>${escapeHtml(expectation.styleId ?? "—")}</td><td>${paragraph.baseline.lineCount}</td><td>${paragraph.candidate.lineCount}</td><td>${escapeHtml(paragraph.issues.map((issue) => issue.code).join(", ") || "通过")}</td></tr>`;
|
||||
return `<tr><td>${expectation.index + 1}</td><td>${escapeHtml(expectation.section)}</td><td>${escapeHtml(expectation.blockKind ?? expectation.role)}</td><td>${escapeHtml(expectation.styleId ?? "—")}</td><td>${paragraph.baseline.lineCount}</td><td>${paragraph.candidate.lineCount}</td><td>${escapeHtml(paragraph.issues.map((issue) => issue.code).join(", ") || "通过")}</td></tr>`;
|
||||
})
|
||||
.join("");
|
||||
return `<div class="paragraph-layouts">
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -10,7 +10,12 @@ import type {
|
||||
const ZERO_WIDTH_AND_CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u200b-\u200d\u2060\ufeff]/gu;
|
||||
const CJK_RADICAL_VARIANTS = new Map([
|
||||
["⺠", "民"],
|
||||
["⻅", "见"],
|
||||
["⻆", "角"],
|
||||
["⻓", "长"],
|
||||
["⻔", "门"],
|
||||
["⻚", "页"],
|
||||
["⻛", "风"],
|
||||
]);
|
||||
const PAGE_NUMBER_PATTERNS = [
|
||||
/^(?:[-—–]\s*)?\d+(?:\s*[//]\s*\d+)?(?:\s*[-—–])?$/u,
|
||||
|
||||
@@ -56,11 +56,24 @@ export type EditableParagraphRole =
|
||||
| "caption"
|
||||
| "other";
|
||||
|
||||
export type EditableParagraphBlockKind =
|
||||
| "paragraph"
|
||||
| "heading"
|
||||
| "list-item"
|
||||
| "table-header"
|
||||
| "table-cell"
|
||||
| "code-block"
|
||||
| "block-quote"
|
||||
| "caption"
|
||||
| "semantic-region"
|
||||
| "other";
|
||||
|
||||
export interface EditableParagraphExpectation {
|
||||
index: number;
|
||||
text: string;
|
||||
styleId?: string;
|
||||
role: EditableParagraphRole;
|
||||
blockKind?: EditableParagraphBlockKind;
|
||||
section: "cover" | "body";
|
||||
}
|
||||
|
||||
@@ -180,6 +193,10 @@ export interface VisualDiffThresholds {
|
||||
maxChangedPixelRatio: number;
|
||||
minInkIou: number;
|
||||
minEdgeIou: number;
|
||||
maxBackgroundColorDelta: number;
|
||||
maxForegroundColorDelta: number;
|
||||
minAntialiasEquivalentInkIou: number;
|
||||
minAntialiasEquivalentEdgeIou: number;
|
||||
}
|
||||
|
||||
export type VisualDiffIssueSeverity = "warning" | "failure";
|
||||
@@ -271,6 +288,8 @@ export interface PdfRasterDiffMetrics {
|
||||
changedPixelRatio: number;
|
||||
inkIou: number;
|
||||
edgeIou: number;
|
||||
backgroundColorDelta: number;
|
||||
foregroundColorDelta: number;
|
||||
}
|
||||
|
||||
export interface ComparePageRasterOptions {
|
||||
|
||||
@@ -121,6 +121,38 @@ describe("PDF 基础视觉门禁", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("逐段完整映射时忽略 PDF 页码与表格行聚合造成的串联顺序污染", async () => {
|
||||
const baseline = withLines(await snapshot("baseline"), [
|
||||
textLine("门禁 内容", 100),
|
||||
]);
|
||||
const candidate = withLines(await snapshot("candidate"), [
|
||||
textLine("门禁 2 / 2 内容", 100),
|
||||
]);
|
||||
const result = comparePdfSnapshotsBasic(baseline, candidate, {}, {
|
||||
expectedEditableText: "门禁内容",
|
||||
expectedEditableParagraphs: [
|
||||
{
|
||||
index: 0,
|
||||
text: "门禁",
|
||||
role: "body",
|
||||
blockKind: "table-header",
|
||||
section: "body",
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
text: "内容",
|
||||
role: "body",
|
||||
blockKind: "table-header",
|
||||
section: "body",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.candidateEditableExact).toBe(true);
|
||||
expect(result.issues.map((issue) => issue.code)).not.toContain(
|
||||
"CONTENT_MISMATCH",
|
||||
);
|
||||
});
|
||||
|
||||
it("通过纸张和正文一致的文档", async () => {
|
||||
const baseline = await snapshot("baseline");
|
||||
const candidate = await snapshot("candidate");
|
||||
|
||||
@@ -59,6 +59,95 @@ function expectation(
|
||||
}
|
||||
|
||||
describe("PDF 内容感知段落版式门禁", () => {
|
||||
it("同一物理行中的表格单元格分别保留自己的视觉边界", () => {
|
||||
const tableLine = line("建设内容 主要能力", 220, 320, 72);
|
||||
tableLine.items = [
|
||||
{
|
||||
text: "建设内容",
|
||||
normalizedText: "建设内容",
|
||||
bounds: { x: 72, y: 210, width: 48, height: 12 },
|
||||
baselineY: 220,
|
||||
fontFamily: "Test Serif",
|
||||
hasEol: false,
|
||||
},
|
||||
{
|
||||
text: "主要能力",
|
||||
normalizedText: "主要能力",
|
||||
bounds: { x: 240, y: 210, width: 48, height: 12 },
|
||||
baselineY: 220,
|
||||
fontFamily: "Test Serif",
|
||||
hasEol: true,
|
||||
},
|
||||
];
|
||||
const paragraphs = [
|
||||
expectation("建设内容", { index: 0, blockKind: "table-header" }),
|
||||
expectation("主要能力", { index: 1, blockKind: "table-header" }),
|
||||
];
|
||||
const results = comparePdfEditableParagraphLayouts(
|
||||
snapshot("baseline", [[tableLine]]),
|
||||
snapshot("candidate", [[tableLine]]),
|
||||
paragraphs,
|
||||
);
|
||||
|
||||
expect(results[0]?.baseline.visualLines[0]?.bounds).toEqual(
|
||||
tableLine.items[0]?.bounds,
|
||||
);
|
||||
expect(results[1]?.baseline.visualLines[0]?.bounds).toEqual(
|
||||
tableLine.items[1]?.bounds,
|
||||
);
|
||||
});
|
||||
|
||||
it("按字符类别估算合并文本项中的有序列表正文边界", () => {
|
||||
const listLine = line("3.系统联调", 220, 100, 100);
|
||||
listLine.items = [
|
||||
{
|
||||
text: "3. 系统联调",
|
||||
normalizedText: "3. 系统联调",
|
||||
bounds: { x: 100, y: 210, width: 100, height: 12 },
|
||||
baselineY: 220,
|
||||
fontFamily: "Test Serif",
|
||||
hasEol: true,
|
||||
},
|
||||
];
|
||||
const [result] = comparePdfEditableParagraphLayouts(
|
||||
snapshot("baseline", [[listLine]]),
|
||||
snapshot("candidate", [[listLine]]),
|
||||
[expectation("系统联调", { blockKind: "list-item" })],
|
||||
);
|
||||
|
||||
expect(result?.baseline.visualLines[0]?.bounds.x).toBeCloseTo(117.18, 1);
|
||||
expect(result?.baseline.visualLines[0]?.bounds.width).toBeCloseTo(82.82, 1);
|
||||
});
|
||||
|
||||
it("跳过被无关字符打断的相似文本并匹配后续完整段落", () => {
|
||||
const [result] = comparePdfEditableParagraphLayouts(
|
||||
snapshot("baseline", [[
|
||||
line("甲X乙", 180),
|
||||
line("甲乙", 220),
|
||||
]]),
|
||||
snapshot("candidate", [[
|
||||
line("甲X乙", 180),
|
||||
line("甲乙", 220),
|
||||
]]),
|
||||
[expectation("甲乙")],
|
||||
);
|
||||
|
||||
expect(result?.status).toBe("passed");
|
||||
expect(result?.baseline.firstLineBaselineYPt).toBe(220);
|
||||
expect(result?.candidate.firstLineBaselineYPt).toBe(220);
|
||||
});
|
||||
|
||||
it("连续段落可以自然跨越多个 PDF 视觉行", () => {
|
||||
const [result] = comparePdfEditableParagraphLayouts(
|
||||
snapshot("baseline", [[line("甲乙", 180), line("丙丁", 204)]]),
|
||||
snapshot("candidate", [[line("甲乙", 180), line("丙丁", 204)]]),
|
||||
[expectation("甲乙丙丁")],
|
||||
);
|
||||
|
||||
expect(result?.status).toBe("passed");
|
||||
expect(result?.baseline.lineTexts).toEqual(["甲乙", "丙丁"]);
|
||||
});
|
||||
|
||||
it("允许正文段落整体移动到下一物理页", () => {
|
||||
const baseline = snapshot("baseline", [
|
||||
[line("建立月度调度机制及时协调", 700), line("解决项目中的问题", 728)],
|
||||
@@ -123,6 +212,30 @@ describe("PDF 内容感知段落版式门禁", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("正文块行数不变时允许三个字符以内的流式行内重排", () => {
|
||||
const baseline = snapshot("baseline", [[
|
||||
line("甲乙丙丁", 220),
|
||||
line("戊己庚辛", 244),
|
||||
]]);
|
||||
const candidate = snapshot("candidate", [[
|
||||
line("甲乙丙丁戊己", 220),
|
||||
line("庚辛", 244),
|
||||
]]);
|
||||
const [result] = comparePdfEditableParagraphLayouts(
|
||||
baseline,
|
||||
candidate,
|
||||
[expectation("甲乙丙丁戊己庚辛", { blockKind: "paragraph" })],
|
||||
);
|
||||
|
||||
expect(result?.status).toBe("warning");
|
||||
expect(result?.issues).toContainEqual(
|
||||
expect.objectContaining({
|
||||
code: "PARAGRAPH_LINE_BREAK_MISMATCH",
|
||||
severity: "warning",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("拒绝短正文或非极短末行的行数差异", () => {
|
||||
const text = "项目建设内容需要严格控制质量进度投资安全风险";
|
||||
const baseline = snapshot("baseline", [
|
||||
|
||||
@@ -53,12 +53,41 @@ describe("PDF 栅格差异", () => {
|
||||
changedPixelRatio: 0,
|
||||
inkIou: 1,
|
||||
edgeIou: 1,
|
||||
backgroundColorDelta: 0,
|
||||
foregroundColorDelta: 0,
|
||||
});
|
||||
expect(result.artifacts.overlayPng.subarray(0, 8)).toEqual(
|
||||
new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||
);
|
||||
});
|
||||
|
||||
it("分别量化块背景色与背景相对的前景形状", async () => {
|
||||
const coloredRaster = (background: string, foreground: string) => {
|
||||
const canvas = createCanvas(80, 40);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = background;
|
||||
context.fillRect(0, 0, 80, 40);
|
||||
context.fillStyle = foreground;
|
||||
context.fillRect(20, 10, 40, 20);
|
||||
const png = Uint8Array.from(canvas.toBuffer("image/png"));
|
||||
return {
|
||||
widthPx: 80,
|
||||
heightPx: 40,
|
||||
dpi: 144,
|
||||
sha256: createHash("sha256").update(png).digest("hex"),
|
||||
png,
|
||||
} satisfies PdfPageRaster;
|
||||
};
|
||||
const sameShape = await comparePageRasters(
|
||||
coloredRaster("#f2f2f2", "#202020"),
|
||||
coloredRaster("#ffffff", "#202020"),
|
||||
);
|
||||
|
||||
expect(sameShape.metrics.inkIou).toBe(1);
|
||||
expect(sameShape.metrics.backgroundColorDelta).toBeGreaterThan(10);
|
||||
expect(sameShape.metrics.foregroundColorDelta).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("量化位移并生成稳定叠加图与热力图", async () => {
|
||||
const result = await comparePageRasters(raster(10), raster(20), {
|
||||
spatialTolerancePx: 0,
|
||||
|
||||
@@ -65,6 +65,25 @@ function blockRaster(color: string): PdfPageRaster {
|
||||
};
|
||||
}
|
||||
|
||||
function antialiasBlockRaster(edgeColor: string): PdfPageRaster {
|
||||
const canvas = createCanvas(40, 50);
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, 40, 50);
|
||||
context.fillStyle = edgeColor;
|
||||
context.fillRect(8, 12, 20, 10);
|
||||
context.fillStyle = "#111111";
|
||||
context.fillRect(10, 14, 16, 6);
|
||||
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,
|
||||
@@ -422,6 +441,62 @@ describe("PDF 视觉差异报告", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("允许块内轻微换行警告伴随正文自然跨页", async () => {
|
||||
const baseline = flowSnapshot(
|
||||
"baseline",
|
||||
[
|
||||
[contentLine("甲乙丙丁", 8), contentLine("戊己庚辛", 12)],
|
||||
[contentLine("壬癸", 8)],
|
||||
],
|
||||
"#111111",
|
||||
);
|
||||
const candidate = flowSnapshot(
|
||||
"candidate",
|
||||
[
|
||||
[
|
||||
contentLine("甲乙丙丁戊己", 8),
|
||||
contentLine("庚辛", 12),
|
||||
contentLine("壬癸", 16),
|
||||
],
|
||||
[],
|
||||
],
|
||||
"#cc0000",
|
||||
);
|
||||
const paragraphs = [
|
||||
{
|
||||
index: 0,
|
||||
text: "甲乙丙丁戊己庚辛",
|
||||
section: "body",
|
||||
blockKind: "paragraph",
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
text: "壬癸",
|
||||
section: "body",
|
||||
blockKind: "paragraph",
|
||||
},
|
||||
] as const;
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
expectedEditableText: "甲乙丙丁戊己庚辛壬癸",
|
||||
expectedEditableParagraphs: paragraphs,
|
||||
baselinePageSemantics: bodySemantics(2),
|
||||
candidatePageSemantics: bodySemantics(2),
|
||||
});
|
||||
|
||||
expect(report.basic.paragraphLayouts?.[0]).toMatchObject({
|
||||
status: "warning",
|
||||
});
|
||||
expect(report.basic.bodyFlow).toMatchObject({
|
||||
detected: true,
|
||||
legal: true,
|
||||
movedParagraphIndexes: [1],
|
||||
});
|
||||
expect(report.pages.map((page) => page.rasterComparisonMode)).toEqual([
|
||||
"body-flow",
|
||||
"body-flow",
|
||||
]);
|
||||
});
|
||||
|
||||
it("整段跨页同时发生换行变化时由语义块门禁失败", async () => {
|
||||
const baseline = flowSnapshot(
|
||||
"baseline",
|
||||
@@ -563,6 +638,32 @@ describe("PDF 视觉差异报告", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("正文文字字形与前景色一致时忽略跨引擎抗锯齿灰度噪声", async () => {
|
||||
const baseline = flowSnapshot(
|
||||
"baseline",
|
||||
[[contentLine("甲", 8)]],
|
||||
"#111111",
|
||||
);
|
||||
const candidate = flowSnapshot(
|
||||
"candidate",
|
||||
[[contentLine("甲", 8)]],
|
||||
"#111111",
|
||||
);
|
||||
baseline.pages[0]!.raster = antialiasBlockRaster("#999999");
|
||||
candidate.pages[0]!.raster = antialiasBlockRaster("#aaaaaa");
|
||||
const report = await createPdfVisualDiffReport(baseline, candidate, {
|
||||
expectedEditableText: "甲",
|
||||
expectedEditableParagraphs: [flowParagraphs[0]!],
|
||||
pageSemantics: bodySemantics(1),
|
||||
});
|
||||
|
||||
expect(report.issues.map((issue) => issue.code)).not.toContain(
|
||||
"SEMANTIC_BLOCK_RASTER_MISMATCH",
|
||||
);
|
||||
expect(report.basic.semanticBlockVisuals[0]?.lines[0]?.metrics)
|
||||
.toMatchObject({ inkIou: 1, edgeIou: 1 });
|
||||
});
|
||||
|
||||
it("缺少语义块证据时不得把正文整页栅格自动降级", async () => {
|
||||
const report = await createPdfVisualDiffReport(
|
||||
snapshot("baseline", raster("#111111")),
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
isCrossEngineRasterEquivalent,
|
||||
type PdfRasterMetrics,
|
||||
} from "../src/index.js";
|
||||
|
||||
function metrics(
|
||||
overrides: Partial<PdfRasterMetrics> = {},
|
||||
): PdfRasterMetrics {
|
||||
return {
|
||||
baselineWidthPx: 224,
|
||||
baselineHeightPx: 37,
|
||||
candidateWidthPx: 224,
|
||||
candidateHeightPx: 37,
|
||||
comparedWidthPx: 224,
|
||||
comparedHeightPx: 37,
|
||||
dimensionsMatch: true,
|
||||
geometryNormalized: true,
|
||||
spatialTolerancePx: 4,
|
||||
meanAbsoluteError: 14.5,
|
||||
changedPixelRatio: 0.27,
|
||||
inkIou: 0.942,
|
||||
edgeIou: 1,
|
||||
backgroundColorDelta: 0.02,
|
||||
foregroundColorDelta: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("跨引擎字形栅格等价", () => {
|
||||
it("接受轮廓、颜色和几何一致的灰阶抗锯齿差异", () => {
|
||||
expect(isCrossEngineRasterEquivalent(metrics(), false)).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
candidateWidthPx: 222,
|
||||
inkIou: 0.88,
|
||||
foregroundColorDelta: 3.5,
|
||||
}),
|
||||
false,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({ inkIou: 1, edgeIou: 0.946 }),
|
||||
false,
|
||||
"table-header",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({ inkIou: 0.969, edgeIou: 0.983 }),
|
||||
false,
|
||||
"code-block",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 38,
|
||||
candidateWidthPx: 38,
|
||||
comparedWidthPx: 38,
|
||||
inkIou: 0.902,
|
||||
edgeIou: 0.904,
|
||||
}),
|
||||
false,
|
||||
"code-block",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 236,
|
||||
candidateWidthPx: 233,
|
||||
baselineHeightPx: 36,
|
||||
candidateHeightPx: 38,
|
||||
inkIou: 0.897,
|
||||
edgeIou: 0.9996,
|
||||
}),
|
||||
false,
|
||||
"paragraph",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 272,
|
||||
candidateWidthPx: 286,
|
||||
baselineHeightPx: 43,
|
||||
candidateHeightPx: 42,
|
||||
inkIou: 0.948,
|
||||
edgeIou: 0.958,
|
||||
foregroundColorDelta: 4.2,
|
||||
}),
|
||||
false,
|
||||
"list-item",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
candidateWidthPx: 209,
|
||||
inkIou: 0.974,
|
||||
edgeIou: 0.984,
|
||||
}),
|
||||
false,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
candidateWidthPx: 208,
|
||||
inkIou: 0.91,
|
||||
edgeIou: 0.94,
|
||||
}),
|
||||
false,
|
||||
"list-item",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("拒绝回流、颜色、几何或轮廓变化", () => {
|
||||
expect(isCrossEngineRasterEquivalent(metrics(), true)).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({ foregroundColorDelta: 4.01 }),
|
||||
false,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({ candidateWidthPx: 220 }),
|
||||
false,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
candidateWidthPx: 207,
|
||||
inkIou: 0.91,
|
||||
edgeIou: 0.94,
|
||||
}),
|
||||
false,
|
||||
"list-item",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
candidateWidthPx: 208,
|
||||
inkIou: 0.974,
|
||||
edgeIou: 0.984,
|
||||
}),
|
||||
false,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
candidateWidthPx: 209,
|
||||
inkIou: 0.91,
|
||||
edgeIou: 0.94,
|
||||
}),
|
||||
false,
|
||||
"paragraph",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(metrics({ edgeIou: 0.96 }), false),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineWidthPx: 38,
|
||||
candidateWidthPx: 38,
|
||||
comparedWidthPx: 38,
|
||||
inkIou: 0.89,
|
||||
edgeIou: 0.904,
|
||||
}),
|
||||
false,
|
||||
"code-block",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isCrossEngineRasterEquivalent(
|
||||
metrics({
|
||||
baselineHeightPx: 37,
|
||||
candidateHeightPx: 40,
|
||||
inkIou: 0.95,
|
||||
edgeIou: 1,
|
||||
}),
|
||||
false,
|
||||
"paragraph",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,7 @@ describe("PDF 文本行聚合", () => {
|
||||
|
||||
it("将字体 ToUnicode 中的传统户部件归一为简体字符", () => {
|
||||
expect(normalizePdfText("戶⼾")).toBe("户户");
|
||||
expect(normalizePdfText("⻅⻆⻓⻔⻚⻛")).toBe("见角长门页风");
|
||||
});
|
||||
|
||||
it("从可编辑正文契约中移除 Word 自动列表装饰符", () => {
|
||||
|
||||
Reference in New Issue
Block a user