openreader/compute/core/src/pdf/stitch.ts

144 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { ParsedPdfDocument, ParsedPdfBlock } from '../types/parsed-pdf';
const STITCHABLE_KINDS = new Set<ParsedPdfBlock['kind']>([
'text',
'content',
'reference_content',
'aside_text',
'abstract',
'algorithm',
'reference',
]);
function stripTrailingClosers(text: string): string {
return text.trim().replace(/[\"'”’\]\)]+$/g, '');
}
function isSentenceTerminal(text: string): boolean {
return /[.!?]$/.test(stripTrailingClosers(text));
}
function canStitch(a: ParsedPdfBlock, b: ParsedPdfBlock): boolean {
if (!STITCHABLE_KINDS.has(a.kind)) return false;
if (a.kind !== b.kind) return false;
if (isSentenceTerminal(a.text)) return false;
const next = b.text.trim();
if (!next) return false;
if (/^[A-Z]/.test(next)) return false;
return true;
}
function splitHeadContinuation(text: string): { continuation: string; remainder: string } {
const normalized = text.replace(/\s+/g, ' ').trim();
if (!normalized) return { continuation: '', remainder: '' };
const CLOSERS = new Set(['"', "'", '”', '', ')', ']', '}']);
const isTerminal = (ch: string): boolean => ch === '.' || ch === '!' || ch === '?';
for (let i = 0; i < normalized.length; i += 1) {
const ch = normalized[i];
if (!isTerminal(ch)) continue;
const prev = i > 0 ? normalized[i - 1] : '';
const next = i + 1 < normalized.length ? normalized[i + 1] : '';
if (ch === '.' && /\d/.test(prev) && /\d/.test(next)) continue;
let cut = i + 1;
while (cut < normalized.length && CLOSERS.has(normalized[cut])) cut += 1;
const after = cut < normalized.length ? normalized[cut] : '';
if (!after || /\s/.test(after) || /[A-Z]/.test(after)) {
return {
continuation: normalized.slice(0, cut).trim(),
remainder: normalized.slice(cut).trim(),
};
}
}
return {
continuation: normalized,
remainder: '',
};
}
const HARD_BOUNDARY_KINDS = new Set<ParsedPdfBlock['kind']>([
'paragraph_title',
'doc_title',
]);
function findTailCandidateIndex(blocks: ParsedPdfBlock[]): number {
for (let i = blocks.length - 1; i >= 0; i -= 1) {
const block = blocks[i];
if (!block || !block.text.trim()) continue;
if (STITCHABLE_KINDS.has(block.kind)) return i;
}
return -1;
}
function findHeadCandidateIndex(blocks: ParsedPdfBlock[]): number {
for (let i = 0; i < blocks.length; i += 1) {
const block = blocks[i];
if (!block || !block.text.trim()) continue;
if (STITCHABLE_KINDS.has(block.kind)) return i;
}
return -1;
}
function hasHardBoundaryBetween(
pageBlocks: ParsedPdfBlock[],
startInclusive: number,
endExclusive: number,
): boolean {
for (let i = startInclusive; i < endExclusive; i += 1) {
const block = pageBlocks[i];
if (block && HARD_BOUNDARY_KINDS.has(block.kind)) return true;
}
return false;
}
export function stitchCrossPageBlocks(doc: ParsedPdfDocument): ParsedPdfDocument {
const pages = doc.pages.map((page) => ({ ...page, blocks: page.blocks.map((b) => ({ ...b, fragments: b.fragments.map((f) => ({ ...f })) })) }));
for (let i = 0; i < pages.length - 1; i += 1) {
const page = pages[i];
const next = pages[i + 1];
const tailIndex = findTailCandidateIndex(page.blocks);
const headIndex = findHeadCandidateIndex(next.blocks);
if (tailIndex < 0 || headIndex < 0) continue;
if (hasHardBoundaryBetween(page.blocks, tailIndex + 1, page.blocks.length)) continue;
if (hasHardBoundaryBetween(next.blocks, 0, headIndex)) continue;
const tail = page.blocks[tailIndex];
const head = next.blocks[headIndex];
if (!tail || !head) continue;
if (!canStitch(tail, head)) continue;
const { continuation, remainder } = splitHeadContinuation(head.text);
if (!continuation) continue;
const continuationFragment = head.fragments[0]
? { ...head.fragments[0], text: continuation }
: null;
if (continuationFragment) {
tail.fragments.push(continuationFragment);
}
tail.text = `${tail.text} ${continuation}`.replace(/\s+/g, ' ').trim();
if (!remainder) {
next.blocks.splice(headIndex, 1);
continue;
}
head.text = remainder;
if (head.fragments[0]) {
head.fragments[0].text = remainder;
}
}
return {
...doc,
pages,
};
}