import { buildPdfEditableContentLines, 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; interface MatchCursor { lineIndex: number; 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; } 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[][], ): 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[][], ) { 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 expectedCharacters = Array.from( normalizePdfEditableText(expectation.text), ); const matchedByLine = new Map(); const matchedRanges = new Map(); 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 (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]!), ); return { pageNumber: line.pageNumber, bounds: matched.bounds, fontFamilies: [ ...new Set( matched.items.flatMap((item) => item.fontFamily ? [item.fontFamily] : [], ), ), ].sort(), }; }); if (matched) { cursor.lineIndex = matchedCursor.lineIndex; 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, ): 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 xDelta = delta(baseline.firstLineXPt, candidate.firstLineXPt); 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, 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 baselineCursor: MatchCursor = { lineIndex: 0, characterIndex: 0 }; const candidateCursor: MatchCursor = { lineIndex: 0, characterIndex: 0 }; return expectations.map((expectation) => { const baselineObservation = observeParagraph( expectation, baselineLines, baselineCharacters, baselineCursor, ); const candidateObservation = observeParagraph( expectation, candidateLines, candidateCharacters, candidateCursor, ); const issues = compareParagraph( expectation, baselineObservation, candidateObservation, baselineCoverPageCount, candidateCoverPageCount, ); return { expectation, baseline: baselineObservation, candidate: candidateObservation, status: status(issues), issues, }; }); }