Files
MorphDoc/packages/document-visual-diff/src/paragraph-layout.ts
T
SkyJourney 64445322eb release: 发布 v0.6.2 DOCX 真实文档修复
新增能力:将 DOCX 发布验收拆分为四套独立 140,支持真实语料冻结、指纹复用、失败与基础设施错误独立统计,并为表格换行、全 JSON 围栏、代码连续性和长文档分页建立通用门禁。

问题修复:冻结 Paged.js 分片前的逻辑表格列轨并传递打印几何,统一 Markdown 表格换行、代码、段落与 OOXML 翻译;改进 PDF 文本流排序、语义块映射、颜色与栅格比较,消除窄字符重叠和跨行范围符号误报。

兼容与部署:版本统一为 0.6.2;正式 Docker 镜像内置固定 Chromium、Pandoc 3.9.0.2 和 Serif/Sans/Mono 字体;Desktop NSIS 与 ZIP 继续直接内置字体,无需系统字体安装。

验证结果:合成基线与长庆严格 280/280,M4N 140/140;健康数据残余误报 6/115(5.22%),均核查为重复表头自动对齐/取样误报且基础设施错误为 0。全项目测试、类型检查、生产构建和 git diff --check 通过;正式 Docker、NSIS、ZIP、离线镜像、部署包、清单及 SHA-256 均已生成并校验。
2026-08-26 10:50:20 +08:00

1691 lines
61 KiB
TypeScript

import {
buildPdfEditableContentLines,
normalizePdfContentText,
normalizePdfEditableText,
} from "./text.js";
import type {
EditableParagraphExpectation,
PdfDocumentSnapshot,
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;
const BASELINE_GAP_TOLERANCE_PT = 1.5;
const COVER_VERTICAL_TOLERANCE_PT = 6;
const BODY_NEAR_BOUNDARY_MIN_CHARACTERS = 30;
const BODY_NEAR_BOUNDARY_MAX_TRAILING_RATIO = 0.05;
const TABLE_COLUMN_CENTER_TOLERANCE_PT = 18;
const TABLE_COLUMN_BOUNDARY_TOLERANCE_PT = 1;
const TABLE_HEADER_CONTINUATION_X_TOLERANCE_PT = 48;
const TABLE_ROW_INITIAL_LINE_TOLERANCE = 8;
const TABLE_ROW_START_SEARCH_LINE_TOLERANCE = 8;
const TABLE_CELL_CONTINUATION_LINE_TOLERANCE = 6;
const TABLE_ROW_CANDIDATE_VERTICAL_DOMAIN_PT = 30;
const TABLE_HEADER_MIN_CLIPPED_IDENTIFIER_COVERAGE = 0.85;
interface MatchCursor {
lineIndex: number;
characterIndex: number;
}
interface ParagraphMatchState {
cursor: MatchCursor;
activeTableGroupId?: string;
activeTableRowIndex?: number;
activeTableRowAnchorLineIndex?: number;
activeTableRowContinuationPageNumber?: number;
activeTableRowContinuationBaselineY?: number;
activeTableRowLastColumnIndex?: number;
activeTableRowLastColumnCenter?: number;
activeTableRowLastColumnRight?: number;
tableStart?: MatchCursor;
tableRowStart?: MatchCursor;
tableHighWater?: MatchCursor;
tableColumnAnchors: Map<number, number>;
tableColumnLeftAnchors: Map<number, number>;
occupiedTableCharacters: Set<string>;
}
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;
}
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 findCharacterSequence(
haystack: readonly string[],
needle: readonly string[],
from = 0,
): number {
if (needle.length === 0) {
return Math.min(from, haystack.length);
}
for (let index = Math.max(0, from); index <= haystack.length - needle.length; index += 1) {
if (needle.every((character, offset) => haystack[index + offset] === character)) {
return index;
}
}
return -1;
}
function matchedLineItems(
line: PdfEditableContentLine["line"],
normalizedLineText: string,
range: MatchedCharacterRange | undefined,
blockKind: EditableParagraphExpectation["blockKind"],
) {
if (!range || line.items.length === 0) {
return { bounds: line.bounds, items: line.items };
}
const selected = [];
const normalizedLineCharacters = Array.from(normalizedLineText);
let searchFrom = 0;
for (const item of line.items) {
// 几何映射必须保留项目符号;可编辑文本匹配仍由行级归一化负责剥离。
const text = normalizePdfContentText(item.normalizedText);
if (!text) {
continue;
}
const characters = Array.from(text);
let start = findCharacterSequence(
normalizedLineCharacters,
characters,
searchFrom,
);
let itemOffset = 0;
let mappedLength = characters.length;
if (start < 0) {
// PDF.js/Office 有时把项目符号和正文合并为同一个 text item,
// 但可编辑行归一化会剥离项目符号。此时按正文在 item 内的真实偏移
// 裁剪,而不是回退到包含项目符号的整行几何。
const embeddedLineStart = findCharacterSequence(
characters,
normalizedLineCharacters,
);
if (embeddedLineStart < 0 || searchFrom > 0) {
continue;
}
start = 0;
itemOffset = embeddedLineStart;
mappedLength = normalizedLineCharacters.length;
}
const end = start + mappedLength;
searchFrom = end;
const overlapStart = Math.max(start, range.start);
const overlapEnd = Math.min(end, range.end);
if (overlapStart >= overlapEnd) {
continue;
}
const leftRatio = weightedCharacterRatio(
characters,
itemOffset + overlapStart - start,
);
const rightRatio = weightedCharacterRatio(
characters,
itemOffset + overlapEnd - start,
);
const unmatchedPrefix = characters
.slice(0, itemOffset + overlapStart - start)
.join("");
const listMarkerGap = blockKind === "list-item" &&
/^(?:[\p{L}\p{N}]+[.)、.]|[☐☑☒•◦▪\uf0b7])$/u.test(unmatchedPrefix)
? line.bounds.height * 0.525
: 0;
selected.push({
item,
bounds: {
...item.bounds,
x: item.bounds.x + item.bounds.width * leftRatio + listMarkerGap,
width: Math.max(
0,
item.bounds.width * (rightRatio - leftRatio) - listMarkerGap,
),
},
});
}
return {
bounds: unionItemBounds(selected) ?? line.bounds,
items: selected.length > 0
? selected.map((entry) => entry.item)
: line.items,
};
}
function advanceCursor(
cursor: MatchCursor,
lineCharacters: readonly string[][],
): boolean {
cursor.characterIndex += 1;
while (
cursor.lineIndex < lineCharacters.length &&
cursor.characterIndex >= (lineCharacters[cursor.lineIndex]?.length ?? 0)
) {
cursor.lineIndex += 1;
cursor.characterIndex = 0;
}
return cursor.lineIndex < lineCharacters.length;
}
function matchContiguousCharacters(
start: MatchCursor,
expectedCharacters: readonly string[],
lineCharacters: readonly string[][],
mathCharacterIndexes: ReadonlySet<number> = new Set(),
) {
const cursor = { ...start };
const positions: MatchedCharacterPosition[] = [];
for (
let expectedIndex = 0;
expectedIndex < expectedCharacters.length;
expectedIndex += 1
) {
const expectedCharacter = expectedCharacters[expectedIndex]!;
const actualCharacter =
lineCharacters[cursor.lineIndex]?.[cursor.characterIndex];
const mathTextLayerEquivalent =
mathCharacterIndexes.has(expectedIndex) &&
actualCharacter !== undefined &&
/^\p{L}$/u.test(actualCharacter) &&
/^\p{L}$/u.test(expectedCharacter);
if (actualCharacter !== expectedCharacter && !mathTextLayerEquivalent) {
if (mathCharacterIndexes.has(expectedIndex)) {
let mathEndIndex = expectedIndex + 1;
while (
mathEndIndex < expectedCharacters.length &&
mathCharacterIndexes.has(mathEndIndex)
) {
mathEndIndex += 1;
}
const nextExpectedCharacter = expectedCharacters[mathEndIndex];
if (
actualCharacter !== undefined &&
nextExpectedCharacter !== undefined &&
actualCharacter === nextExpectedCharacter
) {
for (
let mathIndex = expectedIndex;
mathIndex < mathEndIndex;
mathIndex += 1
) {
positions.push({
lineIndex: cursor.lineIndex,
characterIndex: cursor.characterIndex,
character: expectedCharacters[mathIndex]!,
});
}
expectedIndex = mathEndIndex - 1;
continue;
}
}
break;
}
positions.push({
lineIndex: cursor.lineIndex,
characterIndex: cursor.characterIndex,
character: expectedCharacter,
});
advanceCursor(cursor, lineCharacters);
}
return { cursor, positions };
}
function matchInterleavedTableCellSegments(
start: MatchCursor,
segments: readonly string[],
lines: readonly PdfEditableContentLine[],
lineCharacters: readonly string[][],
occupiedCharacters: ReadonlySet<string>,
preferredX?: number,
initialLineLimit?: number,
minimumInitialX?: number,
initialColumnMinX?: number,
initialColumnMaxX?: number,
alternateColumnMinX?: number,
alternateColumnMaxX?: number,
preferLeftmostInitial = false,
preserveLeftmostAcrossLines = false,
continuationXTolerance = TABLE_COLUMN_CENTER_TOLERANCE_PT,
preferCenterContinuation = false,
preferEarliestSamePageContinuation = false,
initialPageNumber?: number,
preferredRowY?: number,
preferredContinuationPageNumber?: number,
preferredContinuationRowY?: number,
preferEarliestInitialSegment = false,
) {
const positions: MatchedCharacterPosition[] = [];
let segmentStart: MatchCursor = { ...start };
let anchorX = preferredX;
let continuationLeftX: number | undefined;
for (const segment of segments) {
const expectedCharacters = Array.from(normalizePdfEditableText(segment));
if (expectedCharacters.length === 0) {
continue;
}
let expectedIndex = 0;
let searchCursor = { ...segmentStart };
let lastPosition: MatchedCharacterPosition | undefined;
while (
expectedIndex < expectedCharacters.length &&
searchCursor.lineIndex < lineCharacters.length
) {
const searchingInitialChunk = positions.length === 0 && expectedIndex === 0;
const remainingLength = expectedCharacters.length - expectedIndex;
// 窄表格列会被 PDF.js 拆成逐字视觉行(例如 O / D / S、层 / 级)。
// 表格组、行锚点和列中心已经限定候选区域,因此首段允许从单字开始,
// 再沿同一列逐行完成;普通正文仍使用独立的连续匹配逻辑。
const minimumInitialRun = Math.min(1, remainingLength);
let selected:
| {
lineIndex: number;
characterIndex: number;
length: number;
x: number;
leftX: number;
y: number;
}
| undefined;
let fallback:
| {
lineIndex: number;
characterIndex: number;
length: number;
x: number;
leftX: number;
y: number;
}
| undefined;
const preferBestWithinWindow =
initialLineLimit !== undefined || expectedIndex > 0 || positions.length > 0;
const continuationBaseLimit =
searchCursor.lineIndex + TABLE_CELL_CONTINUATION_LINE_TOLERANCE;
const continuationReferencePosition = lastPosition ?? positions.at(-1);
const continuationStartPage = continuationReferencePosition === undefined
? undefined
: lines[continuationReferencePosition.lineIndex]?.pageNumber;
const crossesPageInsideContinuationWindow =
continuationStartPage !== undefined &&
lines
.slice(searchCursor.lineIndex, continuationBaseLimit)
.some((line) => line.pageNumber !== continuationStartPage);
const continuationLineLimit = continuationBaseLimit +
(crossesPageInsideContinuationWindow
? TABLE_ROW_INITIAL_LINE_TOLERANCE
: 0);
for (
let lineIndex = searchCursor.lineIndex;
lineIndex < lineCharacters.length && (
positions.length === 0 && initialLineLimit !== undefined
? lineIndex <= initialLineLimit
: expectedIndex === 0 && positions.length === 0
? true
: lineIndex <
continuationLineLimit
);
lineIndex += 1
) {
if (
searchingInitialChunk &&
selected !== undefined &&
lines[lineIndex]?.pageNumber !== lines[selected.lineIndex]?.pageNumber
) {
break;
}
if (
searchingInitialChunk &&
initialPageNumber !== undefined &&
lines[lineIndex]?.pageNumber !== initialPageNumber
) {
continue;
}
const characters = lineCharacters[lineIndex] ?? [];
const from = lineIndex === searchCursor.lineIndex
? searchCursor.characterIndex
: 0;
const lineCandidates: Array<{
lineIndex: number;
characterIndex: number;
length: number;
x: number;
leftX: number;
y: number;
}> = [];
for (let characterIndex = from; characterIndex < characters.length; characterIndex += 1) {
if (characters[characterIndex] !== expectedCharacters[expectedIndex]) {
continue;
}
if (occupiedCharacters.has(`${lineIndex}:${characterIndex}`)) {
continue;
}
let length = 0;
while (
characterIndex + length < characters.length &&
expectedIndex + length < expectedCharacters.length &&
!occupiedCharacters.has(`${lineIndex}:${characterIndex + length}`) &&
characters[characterIndex + length] ===
expectedCharacters[expectedIndex + length]
) {
length += 1;
}
const line = lines[lineIndex];
if (!line || length === 0) {
continue;
}
if (line.line.items.length > 0 && length > 1) {
let previousCharacterBounds: PdfPointBounds | undefined;
const knownColumnRightX = Math.max(
...[
initialColumnMaxX,
alternateColumnMaxX,
].filter((value): value is number => value !== undefined),
Number.NEGATIVE_INFINITY,
);
for (let offset = 0; offset < length; offset += 1) {
const characterBounds = matchedLineItems(
line.line,
line.normalizedText,
{
start: characterIndex + offset,
end: characterIndex + offset + 1,
},
"table-cell",
).bounds;
const remainsInsideKnownColumn =
Number.isFinite(knownColumnRightX) &&
characterBounds.x + characterBounds.width <=
knownColumnRightX + TABLE_COLUMN_BOUNDARY_TOLERANCE_PT;
if (
previousCharacterBounds &&
characterBounds.x -
(previousCharacterBounds.x + previousCharacterBounds.width) >
line.line.bounds.height * 0.75 &&
!remainsInsideKnownColumn
) {
length = offset;
break;
}
previousCharacterBounds = characterBounds;
}
}
if (length === 0) {
continue;
}
const bounds = matchedLineItems(
line.line,
line.normalizedText,
{ start: characterIndex, end: characterIndex + length },
"table-cell",
).bounds;
const x = bounds.x + bounds.width / 2;
const leftX = bounds.x;
const y = line.line.baselineY;
if (
positions.length === 0 &&
minimumInitialX !== undefined &&
leftX < minimumInitialX - TABLE_COLUMN_BOUNDARY_TOLERANCE_PT
) {
continue;
}
lineCandidates.push({ lineIndex, characterIndex, length, x, leftX, y });
}
const targetX = positions.length === 0 && expectedIndex === 0
? anchorX
: preferCenterContinuation
? anchorX
: continuationLeftX ?? anchorX;
const initialChunk = positions.length === 0 && expectedIndex === 0;
const candidateX = (candidate: (typeof lineCandidates)[number]) =>
preferCenterContinuation
? candidate.x
: candidate.leftX;
const candidateRowDistance = (
candidate: (typeof lineCandidates)[number],
) => preferredRowY === undefined
? 0
: Math.abs(candidate.y - preferredRowY);
const candidateContinuationRowDistance = (
candidate: (typeof lineCandidates)[number],
) => preferredContinuationRowY === undefined ||
preferredContinuationPageNumber === undefined ||
lines[candidate.lineIndex]?.pageNumber !==
preferredContinuationPageNumber
? Number.POSITIVE_INFINITY
: Math.abs(candidate.y - preferredContinuationRowY);
const isWithinTargetColumn = (
candidate: (typeof lineCandidates)[number],
) => {
if (targetX === undefined) {
return true;
}
const sameLineContinuation = !initialChunk &&
continuationReferencePosition !== undefined &&
candidate.lineIndex === continuationReferencePosition.lineIndex;
const boundedSameLineContinuation = sameLineContinuation &&
(
!preferCenterContinuation ||
initialColumnMaxX !== undefined ||
alternateColumnMaxX !== undefined
);
if (
(initialChunk || boundedSameLineContinuation) &&
(
minimumInitialX !== undefined ||
initialColumnMinX !== undefined ||
initialColumnMaxX !== undefined ||
alternateColumnMinX !== undefined ||
alternateColumnMaxX !== undefined
)
) {
const insidePrimary =
(initialColumnMinX === undefined ||
candidate.x >= initialColumnMinX - TABLE_COLUMN_BOUNDARY_TOLERANCE_PT) &&
(initialColumnMaxX === undefined ||
candidate.x <= initialColumnMaxX + TABLE_COLUMN_BOUNDARY_TOLERANCE_PT);
const insideAlternate = !/^\d$/u.test(expectedCharacters[0] ?? "") &&
(alternateColumnMinX !== undefined || alternateColumnMaxX !== undefined) &&
(alternateColumnMinX === undefined ||
candidate.leftX >= alternateColumnMinX - TABLE_COLUMN_BOUNDARY_TOLERANCE_PT) &&
(alternateColumnMaxX === undefined ||
candidate.leftX <= alternateColumnMaxX + TABLE_COLUMN_BOUNDARY_TOLERANCE_PT);
const insideRowDomain =
(minimumInitialX === undefined ||
candidate.leftX >= minimumInitialX - TABLE_COLUMN_BOUNDARY_TOLERANCE_PT) &&
(
(
initialColumnMaxX === undefined ||
candidate.x <= initialColumnMaxX + TABLE_COLUMN_BOUNDARY_TOLERANCE_PT
) ||
(
alternateColumnMaxX !== undefined &&
candidate.leftX <= alternateColumnMaxX + TABLE_COLUMN_BOUNDARY_TOLERANCE_PT
)
);
return insidePrimary || insideAlternate || insideRowDomain;
}
return Math.abs(candidateX(candidate) - targetX) <=
continuationXTolerance;
};
lineCandidates.sort((left, right) => {
if (targetX !== undefined) {
const leftDistance = Math.abs(candidateX(left) - targetX);
const rightDistance = Math.abs(candidateX(right) - targetX);
const leftInColumn = isWithinTargetColumn(left);
const rightInColumn = isWithinTargetColumn(right);
if (leftInColumn !== rightInColumn) {
return leftInColumn ? -1 : 1;
}
return right.length - left.length ||
leftDistance - rightDistance ||
candidateRowDistance(left) - candidateRowDistance(right) ||
left.characterIndex - right.characterIndex;
}
if (initialChunk && preserveLeftmostAcrossLines) {
return left.leftX - right.leftX || right.length - left.length;
}
return right.length - left.length ||
candidateRowDistance(left) - candidateRowDistance(right) ||
left.characterIndex - right.characterIndex;
});
const bestOnLine = lineCandidates[0];
if (!bestOnLine) {
continue;
}
const bestX = initialChunk ? bestOnLine.x : bestOnLine.leftX;
const fallbackX = initialChunk ? fallback?.x : fallback?.leftX;
const prefersSameColumn = targetX !== undefined && fallback !== undefined &&
fallbackX !== undefined &&
(
Math.abs(bestX - targetX) < Math.abs(fallbackX - targetX) ||
(
Math.abs(bestX - targetX) === Math.abs(fallbackX - targetX) &&
(bestOnLine.length > fallback.length ||
(bestOnLine.length === fallback.length &&
bestOnLine.lineIndex < fallback.lineIndex))
)
);
if (
!fallback ||
(targetX === undefined && bestOnLine.length > fallback.length) ||
prefersSameColumn
) {
fallback = bestOnLine;
}
const acceptable = bestOnLine.length >= minimumInitialRun && (
targetX === undefined || isWithinTargetColumn(bestOnLine)
);
if (acceptable) {
if (initialChunk && preferEarliestInitialSegment) {
// 多个硬换行片段可能围绕同行首列的垂直中心分布,并共享相同
// 首字。行列坐标已经把搜索限制在当前单元格后,首段应保留
// 最早候选;否则靠近行中心的第二段会抢占起点,后续再串入
// 下一表格行中的同名尾字。
if (
!selected ||
bestOnLine.lineIndex < selected.lineIndex ||
(
bestOnLine.lineIndex === selected.lineIndex &&
bestOnLine.length > selected.length
)
) {
selected = bestOnLine;
}
} else if (initialChunk && preferLeftmostInitial) {
// 同一行首列可能被 Office 拆成 D / WS 这类多条视觉行。
// 当前行起点已经由上一行 high-water 限定,优先保留最早的
// 合法首字符,再沿列方向续接;否则稍后正文中的完整同名词
// 会仅凭更长前缀夺走行锚点,并使其余列发生级联错配。
if (
!selected ||
bestOnLine.lineIndex < selected.lineIndex ||
(
bestOnLine.lineIndex === selected.lineIndex &&
bestOnLine.length > selected.length
)
) {
selected = bestOnLine;
}
} else if (initialChunk && preserveLeftmostAcrossLines) {
// 首列没有历史列锚点时,以明显更靠左的候选确定列域;同一
// 近似列内保留较早视觉行,避免稍后的长同前缀跨列抢占。
// 窄表头的后续 ASCII 列也复用该规则,但只能在当前逻辑行
// 的垂直域内竞争,不能被下方数据行中更靠左的同前缀夺走。
const bestInsideRow = preferredRowY === undefined ||
candidateRowDistance(bestOnLine) <=
TABLE_ROW_CANDIDATE_VERTICAL_DOMAIN_PT;
const selectedInsideRow = selected === undefined ||
preferredRowY === undefined ||
candidateRowDistance(selected) <=
TABLE_ROW_CANDIDATE_VERTICAL_DOMAIN_PT;
if (
!selected ||
(
bestInsideRow &&
!selectedInsideRow &&
Math.abs(bestOnLine.leftX - selected.leftX) >
TABLE_COLUMN_CENTER_TOLERANCE_PT
) ||
(
bestInsideRow === selectedInsideRow &&
bestOnLine.leftX <
selected.leftX - TABLE_COLUMN_CENTER_TOLERANCE_PT &&
(
selected.length < remainingLength ||
bestOnLine.length >= selected.length
)
)
) {
selected = bestOnLine;
}
} else {
const selectedX = selected === undefined
? undefined
: candidateX(selected);
const bestDistance = targetX === undefined
? undefined
: Math.abs(candidateX(bestOnLine) - targetX);
const selectedDistance = targetX === undefined || selectedX === undefined
? undefined
: Math.abs(selectedX - targetX);
const bestContinuationRowDistance =
candidateContinuationRowDistance(bestOnLine);
const selectedContinuationRowDistance = selected === undefined
? Number.POSITIVE_INFINITY
: candidateContinuationRowDistance(selected);
const crossPageContinuationCandidate =
!initialChunk &&
continuationReferencePosition !== undefined &&
selected !== undefined &&
lines[continuationReferencePosition.lineIndex]?.pageNumber !==
lines[selected.lineIndex]?.pageNumber &&
lines[bestOnLine.lineIndex]?.pageNumber ===
lines[selected.lineIndex]?.pageNumber &&
(
Math.abs(
(lines[bestOnLine.lineIndex]?.line.bounds.y ?? 0) -
(lines[selected.lineIndex]?.line.bounds.y ?? 0),
) <= Math.max(
lines[bestOnLine.lineIndex]?.line.bounds.height ?? 0,
lines[selected.lineIndex]?.line.bounds.height ?? 0,
) * 2.5 ||
(
bestOnLine.characterIndex === 0 &&
bestOnLine.length ===
(lineCharacters[bestOnLine.lineIndex]?.length ?? 0)
)
);
const sameAnchoredContinuationPageCandidate =
!initialChunk &&
continuationReferencePosition !== undefined &&
selected !== undefined &&
preferredContinuationPageNumber !== undefined &&
lines[continuationReferencePosition.lineIndex]?.pageNumber !==
preferredContinuationPageNumber &&
lines[selected.lineIndex]?.pageNumber ===
preferredContinuationPageNumber &&
lines[bestOnLine.lineIndex]?.pageNumber ===
preferredContinuationPageNumber;
const closerAnchoredContinuationCandidate =
sameAnchoredContinuationPageCandidate &&
bestContinuationRowDistance <
selectedContinuationRowDistance -
TABLE_COLUMN_BOUNDARY_TOLERANCE_PT;
const completeCrossPageHardBreakSegmentCandidate =
!initialChunk &&
segments.length > 1 &&
expectedIndex === 0 &&
continuationReferencePosition !== undefined &&
selected !== undefined &&
lines[continuationReferencePosition.lineIndex]?.pageNumber !==
lines[selected.lineIndex]?.pageNumber &&
lines[bestOnLine.lineIndex]?.pageNumber ===
lines[selected.lineIndex]?.pageNumber &&
selected.length < remainingLength &&
bestOnLine.length === remainingLength;
if (
!selected ||
(
bestOnLine.length > selected.length &&
(
initialChunk ||
bestOnLine.lineIndex === selected.lineIndex ||
crossPageContinuationCandidate ||
closerAnchoredContinuationCandidate ||
completeCrossPageHardBreakSegmentCandidate ||
!preferEarliestSamePageContinuation
) &&
(
!initialChunk ||
preferredRowY === undefined ||
candidateRowDistance(bestOnLine) <=
Math.max(
candidateRowDistance(selected) +
TABLE_COLUMN_BOUNDARY_TOLERANCE_PT,
TABLE_ROW_CANDIDATE_VERTICAL_DOMAIN_PT,
)
)
) ||
(
initialChunk &&
preferredRowY !== undefined &&
candidateRowDistance(selected) >
TABLE_ROW_CANDIDATE_VERTICAL_DOMAIN_PT &&
candidateRowDistance(bestOnLine) <
candidateRowDistance(selected) -
TABLE_COLUMN_BOUNDARY_TOLERANCE_PT
) ||
(
bestOnLine.length === selected.length &&
initialChunk &&
preferredRowY !== undefined &&
candidateRowDistance(bestOnLine) <
candidateRowDistance(selected) -
TABLE_COLUMN_BOUNDARY_TOLERANCE_PT
) ||
(
bestOnLine.length === selected.length &&
sameAnchoredContinuationPageCandidate &&
bestContinuationRowDistance <
selectedContinuationRowDistance -
TABLE_COLUMN_BOUNDARY_TOLERANCE_PT
) ||
(
bestOnLine.length === selected.length &&
crossPageContinuationCandidate &&
bestDistance !== undefined &&
selectedDistance !== undefined &&
bestDistance <
selectedDistance - TABLE_COLUMN_BOUNDARY_TOLERANCE_PT
) ||
(
!initialChunk &&
preferCenterContinuation &&
bestOnLine.length === selected.length &&
lines[bestOnLine.lineIndex]?.pageNumber ===
lines[selected.lineIndex]?.pageNumber &&
Math.abs(
(lines[bestOnLine.lineIndex]?.line.bounds.y ?? 0) -
(lines[selected.lineIndex]?.line.bounds.y ?? 0),
) <= Math.max(
lines[bestOnLine.lineIndex]?.line.bounds.height ?? 0,
lines[selected.lineIndex]?.line.bounds.height ?? 0,
) * 2 &&
bestDistance !== undefined &&
selectedDistance !== undefined &&
bestDistance < selectedDistance
)
) {
selected = bestOnLine;
}
}
if (
searchingInitialChunk &&
!preferLeftmostInitial &&
anchorX !== undefined &&
expectedCharacters.length >= 2 &&
expectedCharacters.slice(0, 2).every((character) =>
/[A-Za-z0-9_]/u.test(character)
) &&
bestOnLine.length >= Math.min(2, remainingLength) &&
(
preferredRowY === undefined ||
candidateRowDistance(bestOnLine) <=
TABLE_ROW_CANDIDATE_VERTICAL_DOMAIN_PT
)
) {
selected = bestOnLine;
break;
}
if (!preferBestWithinWindow) {
break;
}
}
}
selected ??= anchorX === undefined && continuationLeftX === undefined
? fallback
: undefined;
if (!selected) {
break;
}
anchorX ??= selected.x;
continuationLeftX ??= selected.leftX;
for (let offset = 0; offset < selected.length; offset += 1) {
lastPosition = {
lineIndex: selected.lineIndex,
characterIndex: selected.characterIndex + offset,
character: expectedCharacters[expectedIndex + offset]!,
};
positions.push(lastPosition);
}
expectedIndex += selected.length;
searchCursor = expectedIndex < expectedCharacters.length
? {
lineIndex: selected.lineIndex,
characterIndex: selected.characterIndex + selected.length,
}
: {
lineIndex: selected.lineIndex,
characterIndex: selected.characterIndex + selected.length,
};
}
if (expectedIndex < expectedCharacters.length || !lastPosition) {
return { matched: false, positions };
}
segmentStart = {
lineIndex: lastPosition.lineIndex,
characterIndex: lastPosition.characterIndex + 1,
};
}
return { matched: true, positions };
}
function compareCursor(left: MatchCursor, right: MatchCursor): number {
return left.lineIndex - right.lineIndex ||
left.characterIndex - right.characterIndex;
}
function cursorAfterPositions(
positions: readonly MatchedCharacterPosition[],
): MatchCursor | undefined {
const last = positions.reduce<MatchedCharacterPosition | undefined>(
(maximum, position) =>
!maximum ||
position.lineIndex > maximum.lineIndex ||
(
position.lineIndex === maximum.lineIndex &&
position.characterIndex > maximum.characterIndex
)
? position
: maximum,
undefined,
);
return last
? { lineIndex: last.lineIndex, characterIndex: last.characterIndex + 1 }
: undefined;
}
function finishActiveTable(state: ParagraphMatchState): void {
if (state.tableHighWater && compareCursor(state.tableHighWater, state.cursor) > 0) {
state.cursor.lineIndex = state.tableHighWater.lineIndex;
state.cursor.characterIndex = state.tableHighWater.characterIndex;
}
delete state.activeTableGroupId;
delete state.activeTableRowIndex;
delete state.activeTableRowAnchorLineIndex;
delete state.activeTableRowContinuationPageNumber;
delete state.activeTableRowContinuationBaselineY;
delete state.activeTableRowLastColumnIndex;
delete state.activeTableRowLastColumnCenter;
delete state.activeTableRowLastColumnRight;
delete state.tableStart;
delete state.tableRowStart;
delete state.tableHighWater;
state.tableColumnAnchors.clear();
state.tableColumnLeftAnchors.clear();
state.occupiedTableCharacters.clear();
}
function matchedPositionsBounds(
positions: readonly MatchedCharacterPosition[],
lines: readonly PdfEditableContentLine[],
): PdfPointBounds | undefined {
const firstLineIndex = positions[0]?.lineIndex;
if (firstLineIndex === undefined) {
return undefined;
}
const firstLinePositions = positions.filter(
(position) => position.lineIndex === firstLineIndex,
);
const firstLine = lines[firstLineIndex];
if (
!firstLine ||
firstLine.line.items.length === 0 ||
firstLinePositions.length === 0
) {
return undefined;
}
const bounds = matchedLineItems(
firstLine.line,
firstLine.normalizedText,
{
start: Math.min(...firstLinePositions.map((position) => position.characterIndex)),
end: Math.max(...firstLinePositions.map((position) => position.characterIndex)) + 1,
},
"table-cell",
).bounds;
return bounds;
}
function observeParagraph(
expectation: EditableParagraphExpectation,
lines: readonly PdfEditableContentLine[],
lineCharacters: readonly string[][],
state: ParagraphMatchState,
): PdfParagraphLayoutObservation {
const expectedCharacters = Array.from(
normalizePdfEditableText(expectation.text),
);
const mathCharacterIndexes = new Set(expectation.mathCharacterIndexes ?? []);
const matchedByLine = new Map<number, string[]>();
const matchedRanges = new Map<number, MatchedCharacterRange>();
let matchedPositions: MatchedCharacterPosition[] = [];
let matchedCursor: MatchCursor = { ...state.cursor };
let matched = expectedCharacters.length === 0;
const interleavedTableCell =
expectation.blockKind === "table-cell" ||
expectation.blockKind === "table-header";
if (interleavedTableCell) {
const tableGroupId = expectation.tableGroupId ?? "__implicit-table__";
if (state.activeTableGroupId !== tableGroupId) {
finishActiveTable(state);
state.activeTableGroupId = tableGroupId;
state.tableStart = { ...state.cursor };
}
const tableRowIndex = expectation.tableRowIndex;
const tableColumnIndex = expectation.tableColumnIndex;
const hasStableTableCoordinates =
tableRowIndex !== undefined && tableColumnIndex !== undefined;
if (
hasStableTableCoordinates &&
state.activeTableRowIndex !== tableRowIndex
) {
state.activeTableRowIndex = tableRowIndex;
delete state.activeTableRowAnchorLineIndex;
delete state.activeTableRowContinuationPageNumber;
delete state.activeTableRowContinuationBaselineY;
delete state.activeTableRowLastColumnIndex;
delete state.activeTableRowLastColumnCenter;
delete state.activeTableRowLastColumnRight;
state.tableRowStart = {
...(state.tableHighWater ?? state.tableStart ?? state.cursor),
};
}
const tableMatchStart = hasStableTableCoordinates
? state.activeTableRowAnchorLineIndex === undefined
? state.tableRowStart ?? state.tableStart ?? state.cursor
: {
lineIndex: Math.max(
state.tableRowStart?.lineIndex ?? 0,
state.activeTableRowAnchorLineIndex -
TABLE_ROW_INITIAL_LINE_TOLERANCE,
),
characterIndex: 0,
}
: state.tableStart ?? state.cursor;
const preferredColumnX = tableColumnIndex === undefined
? undefined
: state.tableColumnAnchors.get(tableColumnIndex);
const currentColumnLeftX = tableColumnIndex === undefined
? undefined
: state.tableColumnLeftAnchors.get(tableColumnIndex);
const nextColumnLeftX = tableColumnIndex === undefined
? undefined
: state.tableColumnLeftAnchors.get(tableColumnIndex + 1);
const previousColumnLeftX = tableColumnIndex === undefined
? undefined
: state.tableColumnLeftAnchors.get(tableColumnIndex - 1);
const inferredColumnRightX = nextColumnLeftX !== undefined
? nextColumnLeftX - TABLE_COLUMN_BOUNDARY_TOLERANCE_PT * 2
: currentColumnLeftX !== undefined && previousColumnLeftX !== undefined
? currentColumnLeftX + (currentColumnLeftX - previousColumnLeftX)
: undefined;
const advancesStableColumn =
hasStableTableCoordinates &&
state.activeTableRowLastColumnIndex !== undefined &&
tableColumnIndex !== undefined &&
tableColumnIndex > state.activeTableRowLastColumnIndex;
const repeatedStableCell =
hasStableTableCoordinates &&
state.activeTableRowLastColumnIndex === tableColumnIndex;
// 表头常为短文本,使用中心点可以区分居中列;正文单元格可能是很长的
// 行内代码并跨多条视觉行,其中心会随每行长度漂移,因此正文复用已经
// 学到的列左边界。这样同行其他列被 PDF.js 合并时也不会串列。同一稳定
// 单元格内的连续段落可能分别居左、居中或居右,不能把首段左边界继续当成
// 后续段落的横向锚点;字符占用与行序约束仍会阻止回退到已经匹配的段落。
const preferredTableMatchX = repeatedStableCell
? undefined
: expectation.blockKind === "table-header"
? preferredColumnX
: currentColumnLeftX ?? preferredColumnX;
const attempt = matchInterleavedTableCellSegments(
tableMatchStart,
expectation.hardBreakSegments ?? [expectation.text],
lines,
lineCharacters,
state.occupiedTableCharacters,
preferredTableMatchX,
state.activeTableRowAnchorLineIndex === undefined
? hasStableTableCoordinates
? tableMatchStart.lineIndex + TABLE_ROW_START_SEARCH_LINE_TOLERANCE
: undefined
: state.activeTableRowAnchorLineIndex +
TABLE_ROW_INITIAL_LINE_TOLERANCE,
advancesStableColumn
? state.activeTableRowLastColumnRight
: undefined,
undefined,
preferredColumnX === undefined ||
state.tableColumnAnchors.get((tableColumnIndex ?? 0) + 1) === undefined
? undefined
: (
preferredColumnX +
state.tableColumnAnchors.get((tableColumnIndex ?? 0) + 1)!
) / 2,
undefined,
inferredColumnRightX,
hasStableTableCoordinates &&
tableColumnIndex === 0 &&
expectedCharacters.length <= 8,
expectation.blockKind === "table-header" &&
preferredColumnX === undefined,
expectation.blockKind === "table-header"
? TABLE_HEADER_CONTINUATION_X_TOLERANCE_PT
: TABLE_COLUMN_CENTER_TOLERANCE_PT,
expectation.blockKind === "table-header",
expectation.blockKind === "table-cell" && hasStableTableCoordinates,
state.activeTableRowAnchorLineIndex === undefined || advancesStableColumn
? undefined
: lines[state.activeTableRowAnchorLineIndex]?.pageNumber,
state.activeTableRowAnchorLineIndex === undefined
? undefined
: lines[state.activeTableRowAnchorLineIndex]?.line.baselineY,
state.activeTableRowContinuationPageNumber,
state.activeTableRowContinuationBaselineY,
hasStableTableCoordinates &&
(expectation.hardBreakSegments?.filter((segment) =>
normalizePdfEditableText(segment).length > 0
).length ?? 0) > 2,
);
const clippedTrailingIdentifier =
expectation.blockKind === "table-header" &&
/^[A-Za-z_][A-Za-z0-9_]{11,}$/u.test(expectation.text) &&
attempt.positions.length < expectedCharacters.length &&
attempt.positions.length / expectedCharacters.length >=
TABLE_HEADER_MIN_CLIPPED_IDENTIFIER_COVERAGE &&
attempt.positions.every(
(position, index) => position.character === expectedCharacters[index],
) &&
(() => {
const last = attempt.positions.at(-1);
return last !== undefined &&
last.characterIndex ===
(lineCharacters[last.lineIndex]?.length ?? 0) - 1;
})();
matched = attempt.matched || clippedTrailingIdentifier;
matchedPositions = attempt.positions;
if (matched) {
for (const position of matchedPositions) {
state.occupiedTableCharacters.add(
`${position.lineIndex}:${position.characterIndex}`,
);
}
const highWater = cursorAfterPositions(matchedPositions);
if (
highWater &&
(!state.tableHighWater || compareCursor(highWater, state.tableHighWater) > 0)
) {
state.tableHighWater = highWater;
}
if (
hasStableTableCoordinates &&
state.activeTableRowAnchorLineIndex === undefined
) {
state.activeTableRowAnchorLineIndex = Math.min(
...matchedPositions.map((position) => position.lineIndex),
);
}
if (hasStableTableCoordinates && state.activeTableRowAnchorLineIndex !== undefined) {
const anchorPageNumber =
lines[state.activeTableRowAnchorLineIndex]?.pageNumber;
const continuationPositions = matchedPositions.filter((position) =>
anchorPageNumber !== undefined &&
(lines[position.lineIndex]?.pageNumber ?? anchorPageNumber) >
anchorPageNumber
);
const continuationPageNumber = continuationPositions.reduce(
(maximum, position) => Math.max(
maximum,
lines[position.lineIndex]?.pageNumber ?? maximum,
),
state.activeTableRowContinuationPageNumber ?? anchorPageNumber ?? 0,
);
const continuationLine = continuationPositions
.filter((position) =>
lines[position.lineIndex]?.pageNumber === continuationPageNumber
)
.reduce<MatchedCharacterPosition | undefined>(
(minimum, position) =>
minimum === undefined || position.lineIndex < minimum.lineIndex
? position
: minimum,
undefined,
);
if (continuationLine !== undefined) {
const continuationBaselineY =
lines[continuationLine.lineIndex]?.line.baselineY;
if (continuationBaselineY !== undefined) {
state.activeTableRowContinuationPageNumber = continuationPageNumber;
state.activeTableRowContinuationBaselineY = continuationBaselineY;
}
}
}
const matchedBounds = matchedPositionsBounds(matchedPositions, lines);
if (hasStableTableCoordinates && matchedBounds !== undefined) {
state.activeTableRowLastColumnIndex = tableColumnIndex;
state.activeTableRowLastColumnCenter =
matchedBounds.x + matchedBounds.width / 2;
state.activeTableRowLastColumnRight =
matchedBounds.x + matchedBounds.width;
}
if (
tableColumnIndex !== undefined &&
!state.tableColumnAnchors.has(tableColumnIndex)
) {
if (matchedBounds !== undefined) {
state.tableColumnAnchors.set(
tableColumnIndex,
matchedBounds.x + matchedBounds.width / 2,
);
state.tableColumnLeftAnchors.set(tableColumnIndex, matchedBounds.x);
}
} else if (tableColumnIndex !== undefined && matchedBounds !== undefined) {
state.tableColumnLeftAnchors.set(
tableColumnIndex,
Math.min(
state.tableColumnLeftAnchors.get(tableColumnIndex) ??
matchedBounds.x,
matchedBounds.x,
),
);
}
}
} else {
finishActiveTable(state);
const searchCursor: MatchCursor = { ...state.cursor };
while (!matched && searchCursor.lineIndex < lineCharacters.length) {
if (
(lineCharacters[searchCursor.lineIndex]?.[
searchCursor.characterIndex
] === expectedCharacters[0] ||
(mathCharacterIndexes.has(0) &&
/^\p{L}$/u.test(
lineCharacters[searchCursor.lineIndex]?.[
searchCursor.characterIndex
] ?? "",
) &&
/^\p{L}$/u.test(expectedCharacters[0] ?? "")))
) {
const attempt = matchContiguousCharacters(
searchCursor,
expectedCharacters,
lineCharacters,
mathCharacterIndexes,
);
if (attempt.positions.length > matchedPositions.length) {
matchedPositions = attempt.positions;
}
if (attempt.positions.length === expectedCharacters.length) {
matched = true;
matchedPositions = attempt.positions;
matchedCursor = attempt.cursor;
break;
}
}
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]] : [],
);
const lineTexts = matchedLineIndexes.map((index) =>
(matchedByLine.get(index) ?? []).join(""),
);
let cumulativeCharacters = 0;
const lineBreakOffsets = lineTexts.slice(0, -1).map((text) => {
cumulativeCharacters += Array.from(text).length;
return cumulativeCharacters;
});
const baselineGaps = matchedLines.slice(1).flatMap((line, index) => {
const previous = matchedLines[index];
return previous && previous.pageNumber === line.pageNumber
? [line.line.baselineY - previous.line.baselineY]
: [];
});
const pageNumbers = [...new Set(matchedLines.map((line) => line.pageNumber))];
const firstLine = matchedLines[0];
const averageLineHeightPt = average(
matchedLines.map((line) => line.line.bounds.height),
);
const averageBaselineGapPt = average(baselineGaps);
const exclusiveGeometry = matchedLineIndexes.every(
(index) =>
Array.from(lines[index]?.normalizedText ?? "").length ===
(matchedByLine.get(index)?.length ?? 0),
);
const visualLines = matchedLines.map((line, index) => {
const matched = matchedLineItems(
line.line,
line.normalizedText,
matchedRanges.get(matchedLineIndexes[index]!),
expectation.blockKind,
);
return {
pageNumber: line.pageNumber,
bounds: matched.bounds,
fontFamilies: [
...new Set(
matched.items.flatMap((item) =>
item.fontFamily ? [item.fontFamily] : [],
),
),
].sort(),
};
});
if (matched && !interleavedTableCell) {
state.cursor.lineIndex = matchedCursor.lineIndex;
state.cursor.characterIndex = matchedCursor.characterIndex;
}
return {
matched,
matchedCharacterCount,
expectedCharacterCount: expectedCharacters.length,
pageNumbers,
lineCount: matchedLines.length,
lineTexts,
lineBreakOffsets,
...(firstLine
? {
firstLineXPt: visualLines[0]!.bounds.x,
firstLineBaselineYPt: firstLine.line.baselineY,
}
: {}),
...(average(visualLines.map((line) => line.bounds.width)) === undefined
? {}
: {
maximumLineWidthPt: Math.max(
...visualLines.map((line) => line.bounds.width),
),
}),
...(averageLineHeightPt === undefined
? {}
: { averageLineHeightPt }),
...(averageBaselineGapPt === undefined
? {}
: { averageBaselineGapPt }),
exclusiveGeometry,
visualLines,
};
}
function delta(
baseline: number | undefined,
candidate: number | undefined,
): number | undefined {
return baseline === undefined || candidate === undefined
? undefined
: Math.abs(baseline - candidate);
}
function trailingLineCharacterCount(
observation: PdfParagraphLayoutObservation,
): number {
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,
candidate: PdfParagraphLayoutObservation,
): boolean {
if (
expectation.section !== "body" ||
expectation.role !== "body" ||
baseline.expectedCharacterCount < BODY_NEAR_BOUNDARY_MIN_CHARACTERS ||
Math.abs(baseline.lineCount - candidate.lineCount) !== 1
) {
return false;
}
const moreLines =
baseline.lineCount > candidate.lineCount ? baseline : candidate;
const trailingCharacters = trailingLineCharacterCount(moreLines);
return (
trailingCharacters > 0 &&
trailingCharacters / baseline.expectedCharacterCount <=
BODY_NEAR_BOUNDARY_MAX_TRAILING_RATIO
);
}
function compareParagraph(
expectation: EditableParagraphExpectation,
baseline: PdfParagraphLayoutObservation,
candidate: PdfParagraphLayoutObservation,
baselineCoverPageCount: number,
candidateCoverPageCount: number,
baselineColumnLeftX?: number,
candidateColumnLeftX?: number,
): VisualDiffIssue[] {
const issues: VisualDiffIssue[] = [];
if (!baseline.matched || !candidate.matched) {
issues.push({
code: "EDITABLE_PARAGRAPH_MAPPING_MISMATCH",
severity: "failure",
message: `第 ${expectation.index + 1} 个可编辑段落无法完整映射到 PDF 文本行`,
details: {
paragraphIndex: expectation.index,
baselineCoverage:
baseline.expectedCharacterCount === 0
? 1
: baseline.matchedCharacterCount / baseline.expectedCharacterCount,
candidateCoverage:
candidate.expectedCharacterCount === 0
? 1
: candidate.matchedCharacterCount / candidate.expectedCharacterCount,
},
});
return issues;
}
if (baseline.lineCount !== candidate.lineCount) {
const nearBoundary = isBodyNearBoundaryLineCountDifference(
expectation,
baseline,
candidate,
);
issues.push({
code: nearBoundary
? "BODY_LINE_COUNT_NEAR_BOUNDARY"
: "TEXT_BLOCK_LINE_COUNT_MISMATCH",
severity: nearBoundary ? "warning" : "failure",
message: nearBoundary
? `第 ${expectation.index + 1} 个正文段落仅因末行边界产生一行差异`
: `第 ${expectation.index + 1}${expectation.role === "title" ? "标题" : "段落"}行数不一致:基线 ${baseline.lineCount} 行,候选 ${candidate.lineCount} 行`,
details: {
paragraphIndex: expectation.index,
role: expectation.role,
baselineLineCount: baseline.lineCount,
candidateLineCount: candidate.lineCount,
...(nearBoundary
? {
trailingCharacters: trailingLineCharacterCount(
baseline.lineCount > candidate.lineCount
? baseline
: candidate,
),
trailingRatio:
trailingLineCharacterCount(
baseline.lineCount > candidate.lineCount
? baseline
: candidate,
) / baseline.expectedCharacterCount,
}
: {}),
},
});
} else {
const maximumBreakDelta = Math.max(
0,
...baseline.lineBreakOffsets.map((offset, index) =>
Math.abs(offset - (candidate.lineBreakOffsets[index] ?? offset)),
),
);
if (maximumBreakDelta > LINE_BREAK_CHARACTER_TOLERANCE) {
const toleratedReflow =
allowsBodyTextReflow(expectation) &&
maximumBreakDelta <= BODY_REFLOW_BREAK_CHARACTER_TOLERANCE;
issues.push({
code: "PARAGRAPH_LINE_BREAK_MISMATCH",
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(","),
},
});
}
}
if (expectation.section === "cover") {
const baselineOnCover =
baseline.pageNumbers.length === 1 && baseline.pageNumbers[0] === 1;
const candidateOnCover =
candidate.pageNumbers.length === 1 && candidate.pageNumbers[0] === 1;
const verticalDelta = delta(
baseline.firstLineBaselineYPt,
candidate.firstLineBaselineYPt,
);
if (
!baselineOnCover ||
!candidateOnCover ||
(verticalDelta ?? 0) > COVER_VERTICAL_TOLERANCE_PT
) {
issues.push({
code: "COVER_LAYOUT_MISMATCH",
severity: "failure",
message: `第 ${expectation.index + 1} 个封面段落未保持独立封面页或纵向位置`,
details: {
paragraphIndex: expectation.index,
baselinePages: baseline.pageNumbers.join(","),
candidatePages: candidate.pageNumbers.join(","),
verticalDeltaPt: verticalDelta ?? -1,
tolerancePt: COVER_VERTICAL_TOLERANCE_PT,
},
});
}
} 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) {
const columnRelativeGeometry =
expectation.tableGroupId !== undefined &&
expectation.tableColumnIndex !== undefined &&
baselineColumnLeftX !== undefined &&
candidateColumnLeftX !== undefined;
const baselineGeometryX = baseline.firstLineXPt === undefined
? undefined
: baseline.firstLineXPt -
(columnRelativeGeometry ? baselineColumnLeftX : 0);
const candidateGeometryX = candidate.firstLineXPt === undefined
? undefined
: candidate.firstLineXPt -
(columnRelativeGeometry ? candidateColumnLeftX : 0);
const xDelta = delta(baselineGeometryX, candidateGeometryX);
const xTolerancePt =
expectation.section === "cover"
? COVER_LINE_X_TOLERANCE_PT
: LINE_X_TOLERANCE_PT;
const widthDelta = delta(
baseline.maximumLineWidthPt,
candidate.maximumLineWidthPt,
);
const heightDelta = delta(
baseline.averageLineHeightPt,
candidate.averageLineHeightPt,
);
const baselineGapDelta = delta(
baseline.averageBaselineGapPt,
candidate.averageBaselineGapPt,
);
if (
(xDelta ?? 0) > xTolerancePt ||
(heightDelta ?? 0) > LINE_HEIGHT_TOLERANCE_PT ||
(baselineGapDelta ?? 0) > BASELINE_GAP_TOLERANCE_PT
) {
issues.push({
code: "PARAGRAPH_LINE_GEOMETRY_MISMATCH",
severity: "failure",
message: `第 ${expectation.index + 1} 个段落的行几何偏差超限`,
details: {
paragraphIndex: expectation.index,
xDeltaPt: xDelta ?? 0,
xTolerancePt,
columnRelativeGeometry,
widthDeltaPt: widthDelta ?? 0,
widthDeltaDiagnosticOnly: true,
lineHeightDeltaPt: heightDelta ?? 0,
baselineGapDeltaPt: baselineGapDelta ?? 0,
},
});
}
}
return issues;
}
function status(issues: readonly VisualDiffIssue[]) {
return issues.some((issue) => issue.severity === "failure")
? ("failed" as const)
: issues.length > 0
? ("warning" as const)
: ("passed" as const);
}
export function comparePdfEditableParagraphLayouts(
baseline: PdfDocumentSnapshot,
candidate: PdfDocumentSnapshot,
expectations: readonly EditableParagraphExpectation[],
baselinePageSemantics: readonly VisualPageSemanticExpectation[] = [],
candidatePageSemantics: readonly VisualPageSemanticExpectation[] =
baselinePageSemantics,
): PdfParagraphLayoutComparison[] {
const baselineLines = buildPdfEditableContentLines(
baseline,
baselinePageSemantics,
);
const candidateLines = buildPdfEditableContentLines(
candidate,
candidatePageSemantics,
);
const baselineCharacters = baselineLines.map((line) =>
Array.from(line.normalizedText),
);
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 baselineState: ParagraphMatchState = {
cursor: { lineIndex: 0, characterIndex: 0 },
tableColumnAnchors: new Map(),
tableColumnLeftAnchors: new Map(),
occupiedTableCharacters: new Set(),
};
const candidateState: ParagraphMatchState = {
cursor: { lineIndex: 0, characterIndex: 0 },
tableColumnAnchors: new Map(),
tableColumnLeftAnchors: new Map(),
occupiedTableCharacters: new Set(),
};
return expectations.map((expectation) => {
const baselineObservation = observeParagraph(
expectation,
baselineLines,
baselineCharacters,
baselineState,
);
const candidateObservation = observeParagraph(
expectation,
candidateLines,
candidateCharacters,
candidateState,
);
const issues = compareParagraph(
expectation,
baselineObservation,
candidateObservation,
baselineCoverPageCount,
candidateCoverPageCount,
expectation.tableColumnIndex === undefined
? undefined
: baselineState.tableColumnLeftAnchors.get(expectation.tableColumnIndex),
expectation.tableColumnIndex === undefined
? undefined
: candidateState.tableColumnLeftAnchors.get(expectation.tableColumnIndex),
);
return {
expectation,
baseline: baselineObservation,
candidate: candidateObservation,
status: status(issues),
issues,
};
});
}