// PDF in-book search: text extraction with item geometry, and a matcher // that maps hits back to page-fraction rects for the overlay renderer. // // pdf.js text items carry positional data (transform/width/height in PDF // units at scale 1) but their strings often omit inter-word spaces — gaps // are positional. Pages are therefore joined gap-aware, with a char→item // map so each match can be covered by the rects of the items it spans. export interface PdfSearchItem { /** page-fraction rect of this text item */ x: number; y: number; w: number; h: number; /** char offset of this item's text within the page string */ start: number; length: number; } export interface PdfPageText { index: number; /** normalized, gap-joined page text (lowercased by the matcher) */ text: string; items: PdfSearchItem[]; } export interface PdfSearchHit { page: number; rects: number[][]; pre: string; match: string; post: string; } interface RawItem { str: string; transform: number[]; width: number; height: number; hasEOL: boolean; } /** Join one page's text items into a searchable string + item map. */ export function buildPageText( rawItems: RawItem[], viewportWidth: number, viewportHeight: number, index: number, ): PdfPageText { const vw = viewportWidth || 1; const vh = viewportHeight || 1; let text = ""; const items: PdfSearchItem[] = []; let prevRight: number | null = null; let prevBaseline: number | null = null; for (const item of rawItems) { if (!item.str) continue; const t = item.transform ?? [1, 0, 0, 1, 0, 0]; const baseline = t[5] ?? 0; const x = t[4] ?? 0; const size = Math.abs(item.height) || Math.abs(t[3]) || Math.abs(t[0]) || 10; const w = Math.abs(item.width) || 0; const h = size; let sep = ""; if (text && !text.endsWith(" ") && prevRight != null) { const newLine = item.hasEOL || prevBaseline == null || Math.abs(baseline - prevBaseline) > size * 0.5; const gap = x - prevRight; if (newLine || gap > size * 0.2) sep = " "; } const s = item.str.replace(/\s+/g, " "); const start = text.length + sep.length; text += sep + s; items.push({ x: x / vw, y: (vh - baseline - h) / vh, w: w / vw, h: h / vh, start, length: s.length, }); prevRight = x + w; prevBaseline = baseline; } return { index, text: text.trimStart(), items }; } /** Extract all pages of a PDF via pdf.js, reporting progress 0..1. */ export async function extractPdfPages( pdf: any, onProgress?: (fraction: number) => void, ): Promise { const pages: PdfPageText[] = []; const num = pdf.numPages as number; for (let i = 0; i < num; i++) { const page = await pdf.getPage(i + 1); const viewport = page.getViewport({ scale: 1 }); const tc = await page.getTextContent(); pages.push( buildPageText(tc.items, viewport.width, viewport.height, i), ); onProgress?.((i + 1) / num); } return pages; } const CONTEXT = 60; /** * Case-insensitive search over extracted pages. Returns hits grouped in * page order; each hit carries the page-fraction rects of the items it * spans (capped to keep pathological fills cheap) plus a trimmed excerpt. */ export function searchPdfPages( pages: PdfPageText[], query: string, locales = "en", ): PdfSearchHit[] { const needle = query.toLocaleLowerCase(locales).replace(/\s+/g, " ").trim(); if (!needle) return []; const hits: PdfSearchHit[] = []; for (const page of pages) { const haystack = page.text.toLocaleLowerCase(locales); let from = 0; for (;;) { const s = haystack.indexOf(needle, from); if (s === -1) break; const e = s + needle.length; from = s + Math.max(1, needle.length); const rects: number[][] = []; for (const it of page.items) { if (it.length <= 0) continue; if (it.start + it.length <= s || it.start >= e) continue; if (rects.length >= 12) break; rects.push([it.x, it.y, it.w, it.h]); } if (!rects.length) continue; const pre = page.text.slice(Math.max(0, s - CONTEXT), s); const post = page.text.slice(e, e + CONTEXT); hits.push({ page: page.index, rects, pre: (s > CONTEXT ? "…" : "") + pre.trimStart(), match: page.text.slice(s, e), post: post.trimEnd() + (page.text.length > e + CONTEXT ? "…" : ""), }); } } return hits; }