")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -423,7 +423,7 @@ func ReaderSearchDrawer() templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
Searching… 0 matches No matches
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
Searching… 0 matches No matches
")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
diff --git a/web/src/reader/pdf-search.ts b/web/src/reader/pdf-search.ts
new file mode 100644
index 0000000..e26483f
--- /dev/null
+++ b/web/src/reader/pdf-search.ts
@@ -0,0 +1,163 @@
+// 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;
+}
diff --git a/web/src/reader/reader.ts b/web/src/reader/reader.ts
index 573a36b..d15f04b 100644
--- a/web/src/reader/reader.ts
+++ b/web/src/reader/reader.ts
@@ -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();
+ 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[] {