import { readFile } from 'node:fs/promises'; import { DOMMatrix, ImageData, Path2D, createCanvas } from '@napi-rs/canvas'; import type { PageSelection, PdfSearchHit, RenderedPage } from './types.js'; Object.assign(globalThis, { DOMMatrix, ImageData, Path2D }); async function openPdf(source: string) { const { getDocument } = await import('pdfjs-dist/legacy/build/pdf.mjs'); const bytes = new Uint8Array(await readFile(source)); if ( bytes.length < 5 || bytes[0] !== 0x25 || bytes[1] !== 0x50 || bytes[2] !== 0x44 || bytes[3] !== 0x46 || bytes[4] !== 0x2d ) { throw new Error(`Source is not a PDF: ${source}`); } return getDocument({ data: bytes, useSystemFonts: true }).promise; } export async function pdfPageCount(source: string): Promise { const pdf = await openPdf(source); try { return pdf.numPages; } finally { await pdf.destroy(); } } export async function renderPdfPages( source: string, selection: PageSelection, dpi = 175, ): Promise { if (!Number.isFinite(dpi) || dpi < 72 || dpi > 300) { throw new Error('dpi must be between 72 and 300.'); } const pdf = await openPdf(source); const scale = dpi / 72; try { const rendered: RenderedPage[] = []; for (const pageNumber of selection.pages) { const page = await pdf.getPage(pageNumber); const viewport = page.getViewport({ scale }); const canvas = createCanvas(Math.ceil(viewport.width), Math.ceil(viewport.height)); const canvasContext = canvas.getContext('2d'); await page.render({ canvas: canvas as unknown as HTMLCanvasElement, canvasContext: canvasContext as unknown as CanvasRenderingContext2D, viewport, }).promise; rendered.push({ page: pageNumber, png: canvas.toBuffer('image/png'), width: canvas.width, height: canvas.height, }); page.cleanup(); } return rendered; } finally { await pdf.destroy(); } } type PdfJsTextItem = { str: string; transform: number[]; width: number; height: number; hasEOL: boolean; }; type SearchSegment = { start: number; end: number; bbox: [number, number, number, number]; }; function clampNormalized(value: number): number { return Math.max(0, Math.min(1000, Math.round(value))); } function textItemBbox( item: PdfJsTextItem, viewport: { width: number; height: number; transform: number[] }, transform: (first: number[], second: number[]) => number[], ): [number, number, number, number] { const matrix = transform(viewport.transform, item.transform); const height = Math.max(1, Math.hypot(matrix[2], matrix[3]) || item.height); const x1 = matrix[4]; const y2 = matrix[5]; const x2 = x1 + Math.max(1, item.width); const y1 = y2 - height; return [ clampNormalized((Math.min(x1, x2) / viewport.width) * 1000), clampNormalized((Math.min(y1, y2) / viewport.height) * 1000), clampNormalized((Math.max(x1, x2) / viewport.width) * 1000), clampNormalized((Math.max(y1, y2) / viewport.height) * 1000), ]; } function unionBboxes( bboxes: Array<[number, number, number, number]>, ): [number, number, number, number] { return [ Math.min(...bboxes.map((bbox) => bbox[0])), Math.min(...bboxes.map((bbox) => bbox[1])), Math.max(...bboxes.map((bbox) => bbox[2])), Math.max(...bboxes.map((bbox) => bbox[3])), ]; } export async function searchPdfText( source: string, selection: PageSelection, query: string, options: { caseSensitive?: boolean; maxResults?: number } = {}, ): Promise { const phrase = query.trim(); if (!phrase) throw new Error('query must not be empty.'); 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 pdfJs = await import('pdfjs-dist/legacy/build/pdf.mjs'); const pdf = await openPdf(source); const hits: PdfSearchHit[] = []; try { for (const pageNumber of selection.pages) { const page = await pdf.getPage(pageNumber); const viewport = page.getViewport({ scale: 1 }); const content = await page.getTextContent(); let flattened = ''; const segments: SearchSegment[] = []; for (const rawItem of content.items) { if (!('str' in rawItem)) continue; const item = rawItem as PdfJsTextItem; const text = item.str.trim(); if (!text) continue; if (flattened && !flattened.endsWith('\n')) flattened += ' '; const start = flattened.length; flattened += text; const end = flattened.length; segments.push({ start, end, bbox: textItemBbox(item, viewport, pdfJs.Util.transform), }); if (item.hasEOL) flattened += '\n'; } const haystack = options.caseSensitive ? flattened : flattened.toLocaleLowerCase(); const needle = options.caseSensitive ? phrase : phrase.toLocaleLowerCase(); let cursor = 0; while (cursor <= haystack.length - needle.length && hits.length < maxResults) { const index = haystack.indexOf(needle, cursor); if (index < 0) break; const end = index + needle.length; const matching = segments.filter( (segment) => segment.end > index && segment.start < end, ); if (matching.length > 0) { hits.push({ page: pageNumber, bbox: unionBboxes(matching.map((segment) => segment.bbox)), text: flattened.slice(index, end), source: 'pdf-text', }); } cursor = Math.max(end, index + 1); } page.cleanup(); if (hits.length >= maxResults) break; } return hits; } finally { await pdf.destroy(); } }