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.
Getting Started
Section titled “Getting Started”npm install @prose-reader/enhancer-search @prose-reader/core rxjsBoth 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.
.search()
Section titled “.search()”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))Locating and opening a result
Section titled “Locating and opening a result”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)How it searches
Section titled “How it searches”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-Typeofapplication/xhtml+xml,application/xml,text/htmlortext/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.
Matching
Section titled “Matching”- Case does not matter:
whalealso 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.