diff --git a/src/SearchBar.tsx b/src/SearchBar.tsx
index 4553cde..20bd428 100644
--- a/src/SearchBar.tsx
+++ b/src/SearchBar.tsx
@@ -13,10 +13,12 @@ type FindOpts = { again?: boolean; previous?: boolean; caseSensitive?: boolean;
export function SearchBar({
result,
+ recognizing,
onFind,
onClose,
}: {
result: FindResult;
+ recognizing: boolean;
onFind: (q: string, o: FindOpts) => void;
onClose: () => void;
}) {
@@ -57,7 +59,8 @@ export function SearchBar({
}}
spellCheck={false}
/>
-
{status}
+
{status}
+ {query && recognizing &&
Results may be incomplete while text is being recognized}
diff --git a/src/SearchResults.test.tsx b/src/SearchResults.test.tsx
new file mode 100644
index 0000000..394f4d9
--- /dev/null
+++ b/src/SearchResults.test.tsx
@@ -0,0 +1,300 @@
+// @vitest-environment jsdom
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { SearchResults } from "./SearchResults";
+import type { TextPiece } from "./lib/search";
+import { deferred } from "./testing/deferred";
+
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+class Bus {
+ #handlers = new Map
void>>();
+ on(name: string, fn: () => void) { this.#handlers.set(name, (this.#handlers.get(name) ?? new Set()).add(fn)); }
+ off(name: string, fn: () => void) { this.#handlers.get(name)?.delete(fn); }
+ emit(name: string) { for (const fn of [...(this.#handlers.get(name) ?? [])]) fn(); }
+ count(name: string) { return this.#handlers.get(name)?.size ?? 0; }
+}
+
+type Pages = Record | Error>;
+
+function makePdf(pages: Pages) {
+ const getTextContent = vi.fn();
+ const getPage = vi.fn(async (n: number) => ({
+ getTextContent: async (options: object) => {
+ getTextContent(n, options);
+ const page = pages[n];
+ if (page instanceof Error) throw page;
+ return { items: await page };
+ },
+ }));
+ return { pdf: { getPage } as never, getPage, getTextContent };
+}
+
+/** Matches as the find controller reports them: per page, the offsets of each match and their lengths. */
+function makeController(matches: Record, pageCount = Math.max(...Object.keys(matches).map(Number))) {
+ const pageMatches: (number[] | undefined)[] = Array.from({ length: pageCount }, (_, i) => matches[i + 1]?.offsets);
+ const pageMatchesLength: (number[] | undefined)[] = Array.from({ length: pageCount }, (_, i) => matches[i + 1]?.offsets.map(() => matches[i + 1].length));
+ return { pageMatches, pageMatchesLength };
+}
+
+const manyPages = (count: number): Pages => Object.fromEntries(Array.from({ length: count }, (_, i) => [i + 1, [{ str: `page ${i + 1} has the word needle in it` }]]));
+const manyMatches = (count: number) => Object.fromEntries(Array.from({ length: count }, (_, i) => [i + 1, { offsets: [i + 1 >= 10 ? 15 : 14], length: 6 }]));
+
+let root: Root;
+let container: HTMLElement;
+let bus: Bus;
+let onSelect: ReturnType void>>;
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ container = document.createElement("div");
+ document.body.append(container);
+ root = createRoot(container);
+ bus = new Bus();
+ onSelect = vi.fn<(page: number) => void>();
+});
+afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ vi.useRealTimers();
+});
+
+const mount = async (pdf: never, controller: object) => {
+ await act(async () => { root.render(); });
+};
+/** Lets the debounce that follows a find event run, and the results that follow it. */
+const settle = async (ms = 100) => { await act(async () => { await vi.advanceTimersByTimeAsync(ms); }); };
+const rows = () => [...container.querySelectorAll(".search-result-list button")];
+const heading = () => container.querySelector("h3")!.textContent;
+
+describe("SearchResults", () => {
+ it("refreshes cached snippets when a later search finds newly recognized text", async () => {
+ const pages: Pages = { 1: [{ str: "stamp" }] };
+ const { pdf } = makePdf(pages);
+ const controller = makeController({ 1: { offsets: [0], length: 5 } });
+ await mount(pdf, controller); await settle();
+ expect(rows()[0].querySelector("mark")!.textContent).toBe("stamp");
+ pages[1] = [{ str: "stamp needle" }];
+ controller.pageMatches[0] = [6]; controller.pageMatchesLength[0] = [6];
+ await act(async () => { bus.emit("find"); bus.emit("updatefindmatchescount"); });
+ await settle();
+ expect(rows()[0].querySelector("mark")!.textContent).toBe("needle");
+ });
+ it("asks for a search before there is anything to show", async () => {
+ const { pdf } = makePdf({});
+ await mount(pdf, { pageMatches: [], pageMatchesLength: [] });
+ await settle();
+ expect(container.textContent).toContain("Search above to see matching pages");
+ expect(heading()).toBe("Matching pages 0");
+ expect(rows()).toHaveLength(0);
+ });
+
+ it("copes with a controller that has no matches yet", async () => {
+ const { pdf } = makePdf({});
+ await mount(pdf, {});
+ await settle();
+ expect(heading()).toBe("Matching pages 0");
+ });
+
+ it("lists each page with its matches and the words around the first one", async () => {
+ const { pdf } = makePdf({ 1: [{ str: "see the needle here" }], 3: [{ str: "x needle y needle" }] });
+ await mount(pdf, makeController({ 1: { offsets: [8], length: 6 }, 3: { offsets: [2, 11], length: 6 } }));
+ await settle();
+ expect(heading()).toBe("Matching pages 2");
+ expect(rows().map((r) => r.getAttribute("aria-label"))).toEqual(["Go to page 1, 1 matches", "Go to page 3, 2 matches"]);
+ expect(rows()[0].querySelector("strong")!.textContent).toBe("Page 1 1 match");
+ expect(rows()[1].querySelector("strong")!.textContent).toBe("Page 3 2 matches");
+ expect(rows()[0].querySelector("mark")!.textContent).toBe("needle");
+ expect(rows()[0].textContent).toContain("see the needle here");
+ });
+
+ it("puts a space where a line ends instead of running the words together", async () => {
+ const { pdf } = makePdf({ 1: [{ str: "our identification.", hasEOL: true }, { str: "Invoice number" }] });
+ await mount(pdf, makeController({ 1: { offsets: [19], length: 7 } }));
+ await settle();
+ expect(rows()[0].querySelector("span:not(strong span)")!.textContent).toBe("our identification. Invoice number");
+ expect(rows()[0].querySelector("mark")!.textContent).toBe("Invoice");
+ });
+
+ it("reads each page's text the way find does, so the offsets line up", async () => {
+ const { pdf, getTextContent } = makePdf({ 1: [{ str: "needle" }] });
+ await mount(pdf, makeController({ 1: { offsets: [0], length: 6 } }));
+ await settle();
+ expect(getTextContent).toHaveBeenCalledWith(1, { disableNormalization: true });
+ });
+
+ it("shows no match text when the match has no recorded length", async () => {
+ const { pdf } = makePdf({ 1: [{ str: "needle" }] });
+ await mount(pdf, { pageMatches: [[0]] });
+ await settle();
+ expect(rows()[0].querySelector("mark")!.textContent).toBe("");
+ });
+
+ it("skips pages without matches", async () => {
+ const { pdf, getPage } = makePdf({ 2: [{ str: "needle" }] });
+ await mount(pdf, { pageMatches: [undefined, [0], []], pageMatchesLength: [undefined, [6], []] });
+ await settle();
+ expect(rows()).toHaveLength(1);
+ expect(getPage).toHaveBeenCalledTimes(1);
+ expect(getPage).toHaveBeenCalledWith(2);
+ });
+
+ it("goes to the page that was clicked", async () => {
+ const { pdf } = makePdf({ 4: [{ str: "needle" }] });
+ await mount(pdf, makeController({ 4: { offsets: [0], length: 6 } }));
+ await settle();
+ await act(async () => { rows()[0].click(); });
+ expect(onSelect).toHaveBeenCalledWith(4);
+ });
+
+ it("shows a page it can't read with an empty snippet rather than failing the list", async () => {
+ const { pdf } = makePdf({ 1: new Error("broken page"), 2: [{ str: "needle" }] });
+ await mount(pdf, makeController({ 1: { offsets: [0], length: 6 }, 2: { offsets: [0], length: 6 } }));
+ await settle();
+ expect(rows()).toHaveLength(2);
+ expect(rows()[0].querySelector("mark")!.textContent).toBe("");
+ expect(rows()[1].querySelector("mark")!.textContent).toBe("needle");
+ });
+
+ it("marks a long text as cut on either side", async () => {
+ const { pdf } = makePdf({ 1: [{ str: "a".repeat(80) + "needle" + "b".repeat(150) }] });
+ await mount(pdf, makeController({ 1: { offsets: [80], length: 6 } }));
+ await settle();
+ const text = rows()[0].querySelector("span:not(strong span)")!.textContent!;
+ expect(text.startsWith("..." + "a".repeat(55) + "needle")).toBe(true);
+ expect(text.endsWith("b".repeat(100) + "...")).toBe(true);
+ });
+
+ it("refreshes when find reports new matches or new highlights, and reuses the text it already read", async () => {
+ const { pdf, getPage } = makePdf({ 1: [{ str: "needle" }] });
+ const controller = makeController({ 1: { offsets: [0], length: 6 } });
+ await mount(pdf, controller);
+ await settle();
+ expect(getPage).toHaveBeenCalledTimes(1);
+ bus.emit("updatetextlayermatches");
+ await settle();
+ bus.emit("updatefindmatchescount");
+ await settle();
+ expect(getPage).toHaveBeenCalledTimes(1);
+ expect(rows()).toHaveLength(1);
+ });
+
+ it("waits for find to go quiet before reading anything", async () => {
+ const { pdf, getPage } = makePdf({ 1: [{ str: "needle" }] });
+ await mount(pdf, makeController({ 1: { offsets: [0], length: 6 } }));
+ getPage.mockClear();
+ bus.emit("updatefindmatchescount");
+ await settle(60);
+ bus.emit("updatefindmatchescount");
+ await settle(60);
+ expect(getPage).not.toHaveBeenCalled();
+ await settle(40);
+ expect(getPage).toHaveBeenCalledTimes(1);
+ });
+
+ it("clears the list as soon as a new search starts", async () => {
+ const { pdf } = makePdf({ 1: [{ str: "needle" }] });
+ await mount(pdf, makeController({ 1: { offsets: [0], length: 6 } }));
+ await settle();
+ expect(rows()).toHaveLength(1);
+ await act(async () => { bus.emit("find"); });
+ expect(rows()).toHaveLength(0);
+ expect(heading()).toBe("Matching pages 0");
+ expect(container.textContent).toContain("Search above");
+ });
+
+ it("shows the first 100 matching pages and says so", async () => {
+ const { pdf, getPage } = makePdf(manyPages(120));
+ await mount(pdf, makeController(manyMatches(120)));
+ await settle();
+ await settle(); // the list is built in slices of ten, with a pause between
+ for (let i = 0; i < 12; i++) await settle(1);
+ expect(heading()).toBe("Matching pages 120");
+ expect(rows()).toHaveLength(100);
+ expect(getPage).toHaveBeenCalledTimes(100);
+ expect(container.textContent).toContain("Showing the first 100 matching pages");
+ });
+
+ it("does not say the list is cut when everything is shown", async () => {
+ const { pdf } = makePdf(manyPages(12));
+ await mount(pdf, makeController(manyMatches(12)));
+ await settle();
+ for (let i = 0; i < 3; i++) await settle(1);
+ expect(rows()).toHaveLength(12);
+ expect(container.textContent).not.toContain("Showing the first 100");
+ });
+
+ it("shows the first slice of results while it reads on", async () => {
+ const gate = deferred();
+ const pages = manyPages(12);
+ pages[11] = gate.promise;
+ const { pdf } = makePdf(pages);
+ await mount(pdf, makeController(manyMatches(12)));
+ await settle();
+ expect(rows()).toHaveLength(10);
+ gate.resolve([{ str: "page 11 needle" }]);
+ await settle();
+ expect(rows()).toHaveLength(12);
+ });
+
+ it("abandons a read that a newer search has replaced", async () => {
+ const gate = deferred();
+ const { pdf, getPage } = makePdf({ 1: [{ str: "needle" }], 2: gate.promise, 3: [{ str: "needle" }] });
+ await mount(pdf, makeController({ 1: { offsets: [0], length: 6 }, 2: { offsets: [0], length: 6 }, 3: { offsets: [0], length: 6 } }));
+ await settle();
+ expect(getPage).toHaveBeenCalledTimes(2); // page 3 hasn't been asked for: page 2 is still being read
+ await act(async () => { bus.emit("find"); });
+ gate.resolve([{ str: "needle" }]);
+ await settle(1);
+ expect(getPage).toHaveBeenCalledTimes(2);
+ expect(rows()).toHaveLength(0);
+ });
+
+ it("does not publish a slice that a newer search has replaced", async () => {
+ const gate = deferred();
+ const pages = manyPages(12);
+ pages[10] = gate.promise;
+ const { pdf } = makePdf(pages);
+ await mount(pdf, makeController(manyMatches(12)));
+ await settle();
+ await act(async () => { bus.emit("find"); });
+ gate.resolve([{ str: "page 10 needle" }]);
+ await settle(1);
+ expect(rows()).toHaveLength(0);
+ });
+
+ it("does not publish a finished list that a newer search has replaced", async () => {
+ const gate = deferred();
+ const { pdf } = makePdf({ 1: gate.promise });
+ await mount(pdf, makeController({ 1: { offsets: [0], length: 6 } }));
+ await settle();
+ await act(async () => { bus.emit("find"); });
+ gate.resolve([{ str: "needle" }]);
+ await settle(1);
+ expect(rows()).toHaveLength(0);
+ });
+
+ it("starts afresh for another document", async () => {
+ const first = makePdf({ 1: [{ str: "first needle" }] });
+ await mount(first.pdf, makeController({ 1: { offsets: [6], length: 6 } }));
+ await settle();
+ expect(rows()[0].textContent).toContain("first needle");
+ const second = makePdf({ 1: [{ str: "second needle" }] });
+ await mount(second.pdf, makeController({ 1: { offsets: [7], length: 6 } }));
+ await settle();
+ expect(rows()[0].textContent).toContain("second needle");
+ expect(bus.count("find")).toBe(1);
+ });
+
+ it("stops listening when it goes away", async () => {
+ const { pdf } = makePdf({});
+ await mount(pdf, { pageMatches: [] });
+ expect(bus.count("find")).toBe(1);
+ expect(bus.count("updatefindmatchescount")).toBe(1);
+ expect(bus.count("updatetextlayermatches")).toBe(1);
+ act(() => root.unmount());
+ expect(bus.count("find") + bus.count("updatefindmatchescount") + bus.count("updatetextlayermatches")).toBe(0);
+ root = createRoot(container); // so afterEach has something to unmount
+ });
+});
diff --git a/src/SearchResults.tsx b/src/SearchResults.tsx
index d91514a..fc0aa0f 100644
--- a/src/SearchResults.tsx
+++ b/src/SearchResults.tsx
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import type { PDFDocumentProxy } from "./pdf";
import type { EventBus, PDFFindController } from "./viewer";
+import { pageText, snippetAt, type PageText, type TextPiece } from "./lib/search";
import { keys } from "./platform";
interface PageResult {
@@ -11,6 +12,8 @@ interface PageResult {
after: string;
}
+const UNREADABLE: PageText = { text: "", breaks: [] };
+
export function SearchResults({ pdf, controller, bus, onSelect }: {
pdf: PDFDocumentProxy;
controller: PDFFindController;
@@ -19,7 +22,7 @@ export function SearchResults({ pdf, controller, bus, onSelect }: {
}) {
const [results, setResults] = useState([]);
const [pageCount, setPageCount] = useState(0);
- const textCache = useRef(new Map>());
+ const textCache = useRef(new Map>());
useEffect(() => {
let generation = 0;
@@ -35,18 +38,13 @@ export function SearchResults({ pdf, controller, bus, onSelect }: {
if (request !== generation) return;
let text = textCache.current.get(entry.page);
if (!text) {
- // Match offsets count characters in the text as pdf.js's find controller reads it: not normalized.
- // Normalized text can be longer (a ligature becomes two letters), which moves the marked words.
+ // Read the page the way find does: its offsets are into the text as stored, not as normalized for display.
text = pdf.getPage(entry.page).then(page => page.getTextContent({ disableNormalization: true })).then(content =>
- content.items.map(item => "str" in item ? item.str : "").join(""));
+ pageText(content.items as TextPiece[]));
textCache.current.set(entry.page, text);
}
- const content = await text.catch(() => "");
- const offset = entry.offset;
- next.push({ page: entry.page, count: entry.count,
- before: (offset > 55 ? "..." : "") + content.slice(Math.max(0, offset - 55), offset),
- match: content.slice(offset, offset + entry.length),
- after: content.slice(offset + entry.length, offset + entry.length + 100) + (content.length > offset + entry.length + 100 ? "..." : "") });
+ const content = await text.catch(() => UNREADABLE);
+ next.push({ page: entry.page, count: entry.count, ...snippetAt(content, entry.offset, entry.length) });
if (next.length % 10 === 0 && request === generation) {
setResults([...next]);
await new Promise(resolve => window.setTimeout(resolve, 0));
@@ -62,6 +60,8 @@ export function SearchResults({ pdf, controller, bus, onSelect }: {
const clear = () => {
generation++;
window.clearTimeout(timer);
+ // OCR can add text between searches without replacing the PDF object.
+ textCache.current.clear();
setResults([]);
setPageCount(0);
};
diff --git a/src/SettingsDialog.tsx b/src/SettingsDialog.tsx
index d1bef83..d2a65b8 100644
--- a/src/SettingsDialog.tsx
+++ b/src/SettingsDialog.tsx
@@ -1,3 +1,4 @@
+import { clearRecognizedText } from "./recent";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Modal } from "./Dialogs";
import { CLAUDE_CODE_URL, PROVIDERS, savedProvider, saveAiPreference, signInChatGPT, USAGE_URL, type AiProvider } from "./ai";
@@ -152,6 +153,7 @@ export function SettingsDialog({ initialSection = "General", theme, onTheme, nig
const desktop = window.folioDesktop;
const ai = desktop?.ai;
const [error, setError] = useState(null);
+ const [ocrCleared, setOcrCleared] = useState(false);
const [section, setSection] = useState<"General" | "Ask AI" | "Storage">(initialSection);
// Updates
@@ -262,7 +264,16 @@ export function SettingsDialog({ initialSection = "General", theme, onTheme, nig
)}
- {section === "Storage" && <>
+ {section === "Storage" && <>
+
+
+
+
+
+
diff --git a/src/Welcome.tsx b/src/Welcome.tsx
index ab11a10..d15a604 100644
--- a/src/Welcome.tsx
+++ b/src/Welcome.tsx
@@ -66,6 +66,7 @@ interface Props {
export function Welcome({ active, onPick, onOpenUrl, onOpenRecent, onSample, loading }: Props) {
const [recent, setRecent] = useState([]);
+ const [recentError, setRecentError] = useState(null);
const [version, setVersion] = useState(null);
const [update, setUpdate] = useState(null);
@@ -83,8 +84,8 @@ export function Welcome({ active, onPick, onOpenUrl, onOpenRecent, onSample, loa
}, [active]);
const forget = async (id: string) => {
- await removeRecent(id);
- setRecent((r) => r.filter((f) => f.id !== id));
+ try { await removeRecent(id); setRecent((r) => r.filter((f) => f.id !== id)); setRecentError(null); }
+ catch { setRecentError("Couldn’t remove the recent file and its recognized text. Please try again."); }
};
return (
@@ -123,14 +124,15 @@ export function Welcome({ active, onPick, onOpenUrl, onOpenRecent, onSample, loa