PDFs have fully searchable text (pdf.js text layer) — the previous
reflowable-only gate existed only because foliate's generic search
needs DOM documents that PDF sections don't provide. This adds a PDF
pipeline alongside it:
- Fork d065495 exposes the pdf.js document proxy as book.pdf so the
host can drive text extraction directly.
- New web/src/reader/pdf-search.ts: extractPdfPages() pulls each
page's textContent with item geometry (progress-reported, cached
after first search). PDF text items often omit inter-word spaces
(gaps are positional), so pages are joined gap-aware — baseline
changes, hasEOL, or horizontal gaps past a font-size threshold
become spaces — recording a char→item map. searchPdfPages() does
case-insensitive matching over the joined text and maps each hit
back to the page-fraction rects of the items it spans, with
ellipsized pre/match/post excerpts. Pure functions, unit-sanity
checked (cross-item 'brave new' → two rects).
- runSearch branches: EPUB keeps foliate's DOM search; PDFs search
the extracted pages, group hits per page ('Page 12'), and render
on-page hit rectangles through the existing fraction-rect overlay
(addRectAnnotation) — which re-render automatically when pages
revisit, same as highlights. Clearing the query removes them.
- Results navigate by page index; the 🔍 button and '/' shortcut now
appear for PDFs too (comics remain without searchable text).
164 lines
4.4 KiB
TypeScript
164 lines
4.4 KiB
TypeScript
// 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<PdfPageText[]> {
|
|
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;
|
|
}
|