feat(reader): in-book search for PDFs

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).
This commit is contained in:
2026-08-17 08:04:30 -04:00
parent 5e73b0a4f6
commit 6fc4107e3c
5 changed files with 258 additions and 30 deletions
+89 -24
View File
@@ -4,6 +4,11 @@ import { Overlayer } from "@bookhoard/foliate-js/overlayer.js";
import { Alpine } from "../alpine";
import { loadSettings, saveSettings } from "./settings-manager";
import { getToken } from "../storage";
import {
extractPdfPages,
searchPdfPages,
type PdfPageText,
} from "./pdf-search";
const HIGHLIGHT_COLORS = [
"#ffd54f",
@@ -410,6 +415,8 @@ document.addEventListener("alpine:init", () => {
searchProgress: 0,
searching: false,
searchGen: 0,
pdfPagesCache: null as PdfPageText[] | null,
pdfSearchKeys: [] as string[],
selectionPopover: {
open: false,
mode: "create" as "create" | "edit",
@@ -1441,7 +1448,9 @@ document.addEventListener("alpine:init", () => {
this.bookmarksOpen = opening;
},
toggleSearch() {
if (this.isFixedLayout) return; // search requires reflowable text
// Reflowable books use foliate's DOM search; PDFs use extracted text.
// Comics have no text at all.
if (this.isFixedLayout && !this.isPDF) return;
const opening = !this.searchOpen;
this.closeDrawers();
this.searchOpen = opening;
@@ -1477,42 +1486,98 @@ document.addEventListener("alpine:init", () => {
this.searchProgress = 0;
this.searchGroups = [];
try {
for await (const r of this.view.search({ query: q })) {
if (gen !== this.searchGen) return;
if (r === "done") break;
if (r && typeof r === "object") {
if (typeof r.progress === "number") {
this.searchProgress = r.progress;
continue;
}
if (Array.isArray(r.subitems) && r.subitems.length) {
this.searchGroups.push({
label: r.label || `Section ${this.searchGroups.length + 1}`,
items: r.subitems.map((s: any) => ({
cfi: s.cfi,
pre: s.excerpt?.pre ?? "",
match: s.excerpt?.match ?? "",
post: s.excerpt?.post ?? "",
})),
});
}
}
if (this.isFixedLayout && this.isPDF) {
await this.runPdfSearch(q, gen);
} else {
await this.runEpubSearch(q, gen);
}
} catch (_e) {
/* search errors leave partial results */
}
if (gen === this.searchGen) this.searching = false;
},
async runEpubSearch(q: string, gen: number) {
for await (const r of this.view.search({ query: q })) {
if (gen !== this.searchGen) return;
if (r === "done") break;
if (r && typeof r === "object") {
if (typeof r.progress === "number") {
this.searchProgress = r.progress;
continue;
}
if (Array.isArray(r.subitems) && r.subitems.length) {
this.searchGroups.push({
label: r.label || `Section ${this.searchGroups.length + 1}`,
items: r.subitems.map((s: any) => ({
cfi: s.cfi,
page: null,
pre: s.excerpt?.pre ?? "",
match: s.excerpt?.match ?? "",
post: s.excerpt?.post ?? "",
})),
});
}
}
}
},
async runPdfSearch(q: string, gen: number) {
const pdf = this.book?.pdf;
if (!pdf) return;
if (!this.pdfPagesCache) {
// Extraction reports progress; it's cached so re-searches are instant.
this.pdfPagesCache = await extractPdfPages(pdf, (fraction) => {
if (gen === this.searchGen) this.searchProgress = fraction;
});
}
if (gen !== this.searchGen) return;
const hits = searchPdfPages(this.pdfPagesCache, q);
const byPage = new Map<number, typeof hits>();
for (const hit of hits) {
const list = byPage.get(hit.page) ?? [];
list.push(hit);
byPage.set(hit.page, list);
}
const groups: typeof this.searchGroups = [];
let i = 0;
for (const [page, list] of byPage) {
groups.push({
label: `Page ${page + 1}`,
items: list.map((hit) => {
const key = `pdfsearch:${i++}`;
this.pdfSearchKeys.push(key);
this.renderer?.addRectAnnotation?.({
key,
index: page,
rects: hit.rects,
color: "#ffd54f",
});
return {
cfi: "",
page,
pre: hit.pre,
match: hit.match,
post: hit.post,
};
}),
});
}
this.searchGroups = groups;
},
clearSearchResults() {
this.searchGen++;
this.searching = false;
this.searchGroups = [];
this.searchProgress = 0;
for (const key of this.pdfSearchKeys) {
this.renderer?.removeRectAnnotation?.(key);
}
this.pdfSearchKeys = [];
this.view?.clearSearch?.();
},
goToSearchResult(cfi: string) {
if (!cfi) return;
this.view?.goTo?.(cfi);
goToSearchResult(item: { cfi?: string; page?: number | null }) {
if (item.cfi) this.view?.goTo?.(item.cfi);
else if (item.page != null) this.view?.goTo?.(item.page);
else return;
this.closeDrawers();
},
flattenTOC(items: any[], depth = 0): any[] {