Skip to content

Search enhancer

Add full-text search to a prose reader: find a word or phrase anywhere in an EPUB or PDF, with a CFI for each match to locate it or go to it.

Terminal window
npm install @prose-reader/enhancer-search @prose-reader/core rxjs

Both are peer dependencies: the enhancer runs against the reader your app creates, so it shares that reader's copies rather than bringing its own. It needs the @prose-reader/core it was released with, or a later one of the same major. Its rxjs range is ^7.5.5, the same as core's.

Connect the enhancer to your reader:

import { createReader } from "@prose-reader/core"
import { searchEnhancer } from "@prose-reader/enhancer-search"
const createAppReader = searchEnhancer(createReader)
/**
* There is no configuration for this enhancer.
*/
const reader = createAppReader({ manifest })

The API is under the search namespace. The package exports its types:

type SearchEnhancerAPI = {
readonly __PROSE_READER_ENHANCER_SEARCH: boolean
search: {
search: (text: string) => Observable<SearchResult>
}
}
type SearchResult = ResultItem[]
type ResultItem = {
cfi: string
}

Code that receives a reader can check for __PROSE_READER_ENHANCER_SEARCH to know whether it was created with this enhancer.

function search(text: string): Observable<SearchResult>

Looks for text in the whole book, ignoring case. Every character is matched as itself, . and * included, so the value of a search box can be passed as it is. A match has to sit within one text node: a word that markup splits, such as a drop cap in its own <span>, is not found.

The observable emits once, with every match in reading order: spine item after spine item, then in the order of the document. Then it completes. An empty text emits an empty list.

Each result holds the CFI of a range that covers one match.

reader.search.search("whale").subscribe((results) => {
console.log(`${results.length} matches`)
})

Unsubscribing cancels the search. To search as the user types, let each query replace the previous one with switchMap:

import { debounceTime, distinctUntilChanged, Subject, switchMap } from "rxjs"
const query$ = new Subject<string>()
query$
.pipe(
debounceTime(500),
distinctUntilChanged(),
switchMap((text) => reader.search.search(text)),
)
.subscribe((results) => {
// display the results
})
searchInput.addEventListener("input", () => query$.next(searchInput.value))

A result only holds a CFI. Pass the results to reader.locateResource() to know where each one is: its spine item (itemIndex), its page in the item (itemPageIndex) and in the book (absolutePageIndex), and the matched range once the item is loaded.

import { switchMap } from "rxjs"
reader.search
.search("whale")
.pipe(switchMap((results) => reader.locateResource(results)))
.subscribe((locatedResults) => {
for (const { resource, meta } of locatedResults) {
console.log(resource.cfi, meta.itemIndex, meta.absolutePageIndex)
}
})

locateResource() loads each reflowable item it locates, to find the page of the match, and emits again after every layout. With many results, locate the ones you display, or pass { mode: "shallow" } to locate them without loading items.

Go to a result with reader.navigation.goToCfi():

reader.navigation.goToCfi(result.cfi)

A search renders every spine item headless: it gets a document for the item apart from the displayed book, searches it and releases it. See Headless Rendering. Each step waits until the browser is idle, with requestIdleCallback, or a timeout where it is not available.

What can be searched depends on the item:

  • EPUB chapters and other HTML or XML documents: the resource is parsed when its response has a Content-Type of application/xhtml+xml, application/xml, text/html or text/xml.
  • PDF pages, with the PDF enhancer: the text layer of the page is searched, so a scanned page without text has no matches.
  • Comic pages and other images, and audio tracks have no text to search.
  • Plain-text books (text/plain) are not parsed, so they have no matches either.

An item that fails to load or to be searched adds no results, and the search still completes.

  • Case does not matter: whale also finds "Whale".
  • A match has to be within one text node. A phrase that inline markup splits, such as white <em>whale</em>, is not found as a whole, though each of its words is.
  • Whitespace has to match the document as written: a space in the text does not match a line break or a non-breaking space.