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:
+89
-24
@@ -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[] {
|
||||
|
||||
Reference in New Issue
Block a user