import { readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { defaultOutputDir, parsePageSelection } from './parser.js'; import { pdfPageCount, searchPdfText } from './pdf.js'; import type { LayoutBlock, PdfSearchHit } from './types.js'; async function readParsedBlocks(path: string): Promise { try { const parsed: unknown = JSON.parse(await readFile(path, 'utf8')); if (!Array.isArray(parsed)) return undefined; return parsed.filter( (value): value is LayoutBlock => Boolean( value && typeof value === 'object' && typeof (value as LayoutBlock).page === 'number' && typeof (value as LayoutBlock).text === 'string' && Array.isArray((value as LayoutBlock).bbox), ), ); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; throw error; } } export async function searchPdf( source: string, query: string, options: { pages?: string; artifactDir?: string; caseSensitive?: boolean; maxResults?: number; } = {}, ): Promise<{ hits: PdfSearchHit[]; searched: 'parsed-blocks' | 'pdf-text'; artifactPath?: string; }> { const phrase = query.trim(); if (!phrase) throw new Error('query must not be empty.'); const pageCount = await pdfPageCount(source); const selection = parsePageSelection(options.pages, pageCount); const selected = new Set(selection.pages); const maxResults = options.maxResults ?? 50; if (!Number.isInteger(maxResults) || maxResults < 1 || maxResults > 500) { throw new Error('max_results must be an integer between 1 and 500.'); } const artifactPath = resolve(options.artifactDir ?? defaultOutputDir(source), 'blocks.json'); const blocks = await readParsedBlocks(artifactPath); if (blocks) { const needle = options.caseSensitive ? phrase : phrase.toLocaleLowerCase(); const hits = blocks .filter((block) => { if (!selected.has(block.page)) return false; const text = options.caseSensitive ? block.text : block.text.toLocaleLowerCase(); return text.includes(needle); }) .slice(0, maxResults) .map((block) => ({ page: block.page, bbox: block.bbox, text: block.text, label: block.label, source: 'parsed-blocks', })); if (hits.length > 0) { return { hits, searched: 'parsed-blocks', artifactPath }; } } return { hits: await searchPdfText(source, selection, phrase, options), searched: 'pdf-text', ...(blocks ? { artifactPath } : {}), }; }