Lector
Feature recipes

Search

Index document text, display matches, and jump to highlighted results.

Search has three parts: Search extracts the document's text, useSearch finds matches, and HighlightLayer draws a selected result. Mount Search once per viewer. Keep Pages outside it so indexing does not replace the document with a loading message.

A searchable viewer

This example searches on submit to avoid doing fuzzy matching on every keystroke. It uses the pdf-setup.ts from installation.

searchable-viewer.tsx
"use client";
 
import {
  CanvasLayer, HighlightLayer, Page, Pages, Root, Search, TextLayer,
  calculateHighlightRects, usePdf, usePdfJump, useSearch,
  type SearchResult,
} from "@anaralabs/lector";
import { useState } from "react";
import "./pdf-setup";
 
function SearchPanel() {
  const [query, setQuery] = useState("");
  const [submitted, setSubmitted] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const { search, searchResults } = useSearch();
  const getPage = usePdf((state) => state.getPdfPageProxy);
  const { jumpToHighlightRects } = usePdfJump();
  const setHighlight = usePdf((state) => state.setHighlight);
  const results = [...searchResults.exactMatches, ...searchResults.fuzzyMatches];
 
  async function openResult(result: SearchResult) {
    setError(null);
    try {
      const rects = await calculateHighlightRects(getPage(result.pageNumber), result);
      jumpToHighlightRects(rects, "pixels", "center");
    } catch {
      setError("Couldn't locate this match on the page.");
    }
  }
 
  return (
    <div>
      <form onSubmit={(event) => {
        event.preventDefault();
        search(query, { limit: 20 });
        setHighlight([]);
        setSubmitted(true);
        setError(null);
      }}>
        <label>
          Find in document
          <input value={query} onChange={(event) => setQuery(event.target.value)} />
        </label>
        <button type="submit">Search</button>
      </form>
      <p role="status">
        {submitted ? `${results.length} results shown` : "Enter text to search."}
      </p>
      {error && <p role="alert">{error}</p>}
      <ul>
        {results.map((result) => (
          <li key={`${result.pageNumber}-${result.matchIndex}-${result.isExactMatch}`}>
            <button type="button" onClick={() => void openResult(result)}>
              Page {result.pageNumber}: {result.text}
              {!result.isExactMatch && " (similar match)"}
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}
 
export default function SearchableViewer() {
  return (
    <Root source="/sample.pdf" style={{ height: 600, display: "flex", flexDirection: "column" }}>
      <div style={{ maxHeight: 200, overflow: "auto" }}>
        <Search loading={<p role="status">Indexing document…</p>}>
          <SearchPanel />
        </Search>
      </div>
      <div style={{ flex: 1, minHeight: 0 }}>
        <Pages>
          <Page>
            <CanvasLayer />
            <TextLayer />
            <HighlightLayer style={{ background: "#ffdf6080" }} />
          </Page>
        </Pages>
      </div>
    </Root>
  );
}

calculateHighlightRects uses the result's page number, match index, and searchText to locate the match. Passing the result itself keeps highlighting tied to the submitted query even if the user has since edited the input. It returns pixel rectangles in page coordinates.

Search options and results

search(text, options) is synchronous. It returns the results and also updates searchResults in that hook instance.

OptionDefaultMeaning
threshold0.7Minimum fuzzy similarity; use a value from 0 to 1
limit10Result budget shared between exact and fuzzy groups
textSize100Characters of trailing context to include after a match

Results contain exactMatches, fuzzyMatches, and hasMoreResults. Each match has pageNumber, text, matchIndex, score, isExactMatch, and optional searchText. Matching is case-insensitive. Searching an empty or whitespace-only query clears results.

The current implementation allocates at most ceil(limit / 2) results to exact matches and the remaining budget to fuzzy matches. It can therefore return fewer than limit results even when more exact matches exist. hasMoreResults compares the total candidate count with limit; it is not a reliable count of everything omitted by the group allocation. If you build “show more,” rerun the submitted query with a higher limit rather than treating the result as paginated data.

Practical limits

Search extracts text from all pages, even though Pages only renders a viewport's worth of pages. Mount the search panel on demand for large PDFs. For search-as-you-type, debounce the query and measure with representative documents; lowering the result limit does not stop the full-document matching pass.

Image-only scans need an OCR text layer before they can be searched. PDF text extraction can also differ from visual reading order or spacing. Check the extracted text when a phrase looks present but is not found.

Live example

Exact Search Term Highlighting

This viewer highlights only the exact search term you type

Loading...

Full Context Highlighting

This viewer highlights the entire text chunk containing your search term

Loading...

Responsive search in long documents

useSearch() also exposes searchAsync, cancelSearch, and isSearching. searchAsync returns the same ranked results as search, but yields between batches so the browser can process input. It defaults to an approximate 8 ms work budget. Scheduling can increase total search time; the benefit is responsiveness.

const { searchAsync, cancelSearch, searchResults, isSearching } = useSearch();
const [searchError, setSearchError] = useState<Error | null>(null);
 
useEffect(() => {
  setSearchError(null);
  void searchAsync(debouncedSearchText, { limit: 10 }).catch((error) => {
    if (error.name !== "AbortError") setSearchError(error);
  });
  return cancelSearch;
}, [debouncedSearchText, searchAsync, cancelSearch]);

A newer call to either search method cancels pending async work from that hook. Unmounting or replacing document text also cancels it. Cancelled calls reject with AbortError and never publish partial results. You can pass an external AbortSignal through { signal } or adjust { timeSliceMs } when needed. Keep the component inside <Root> and use <Search> to index the document.

Indexing failures and retry

If text extraction fails, Search keeps its children unmounted and shows an error with a Retry button. Retry starts a new indexing attempt without reloading the viewer. To match your application's error presentation, provide a fallback:

<Search
  errorFallback={({ retry }) => (
    <p role="alert">
      Search is unavailable. <button onClick={retry}>Try again</button>
    </p>
  )}
>
  <SearchPanel />
</Search>

This fragment replaces the existing Search wrapper in the example above. The fallback also receives the original error. It handles indexing failures; Root.onError handles document loading failures.

On this page