Search
The search plugin finds text in the whole document and highlights every match. Register
withSearch(), put <epdf-search-layer> in each page, and search from your own search box:
Loading live preview…
import {
ChangeDetectionStrategy,
Component,
computed,
effect,
inject,
signal,
ViewEncapsulation,
} from '@angular/core';
import { EpdfDocumentGate, provideEmbedPdf } from '@embedpdf/angular/runtime';
import type { OpenInput } from '@embedpdf/angular/runtime';
import { EpdfPageTemplate, EpdfStage, withStage } from '@embedpdf/angular/stage';
import { EpdfRenderLayer, withRender } from '@embedpdf/angular/render';
import { EpdfSearch, EpdfSearchLayer, withSearch } from '@embedpdf/angular/search';
import { localEngine } from '@embedpdf/engine';
const ebook = async (): Promise<OpenInput> => {
const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};
@Component({
selector: 'demo-search-box',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="toolbar">
<input
#field
class="field"
type="search"
aria-label="Search"
placeholder="Search…"
[value]="text()"
(input)="text.set(field.value)"
(keydown.enter)="search.nextHit()"
(keydown.shift.enter)="search.previousHit()"
/>
<output class="readout">{{ count() }}</output>
<button
type="button"
class="button"
aria-label="Previous match"
[disabled]="search.hitCount() === 0"
(click)="search.previousHit()"
>
↑
</button>
<button
type="button"
class="button"
aria-label="Next match"
[disabled]="search.hitCount() === 0"
(click)="search.nextHit()"
>
↓
</button>
</div>
`,
})
export class SearchBox {
protected readonly search = inject(EpdfSearch);
protected readonly text = signal('PDF');
protected readonly count = computed(() => {
if (this.search.hitCount() > 0) {
return `${this.search.activeHitIndex() + 1} of ${this.search.hitCount()}`;
}
if (this.search.status() === 'searching') return 'Searching…';
if (this.search.status() === 'complete') return 'No matches';
return '';
});
constructor() {
// Every change searches again, replacing the last search. An empty text clears it.
effect(() => {
const text = this.text();
void this.search.search({ text });
});
}
}
@Component({
selector: 'demo-root',
imports: [
EpdfDocumentGate,
EpdfStage,
EpdfPageTemplate,
EpdfRenderLayer,
EpdfSearchLayer,
SearchBox,
],
providers: [
provideEmbedPdf(
{ engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
withStage(),
withRender(),
withSearch(),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './basic.css',
encapsulation: ViewEncapsulation.None,
template: `
<ng-container *epdfDocumentGate="let document; fallback: loading">
<demo-search-box />
<epdf-stage class="stage">
<ng-template epdfPage>
<epdf-render-layer />
<epdf-search-layer />
</ng-template>
</epdf-stage>
</ng-container>
<ng-template #loading><p class="loading">Loading…</p></ng-template>
`,
})
export class App {}Try it: type a word, then press Enter for the next match and Shift+Enter for the one before. Each move scrolls the match into view.
import { Component, inject } from '@angular/core';
import { EpdfRenderLayer } from '@embedpdf/angular/render';
import { EpdfSearch, EpdfSearchLayer } from '@embedpdf/angular/search';
import { EpdfPageTemplate, EpdfStage } from '@embedpdf/angular/stage';
@Component({
selector: 'app-search',
imports: [EpdfStage, EpdfPageTemplate, EpdfRenderLayer, EpdfSearchLayer],
template: `
<form (submit)="$event.preventDefault()">
<input #query (input)="search.search({ text: query.value })" />
@if (search.status() === 'searching') {
<span>Searching…</span>
}
@if (search.hitCount() > 0) {
<span>{{ search.activeHitIndex() + 1 }} of {{ search.hitCount() }}</span>
}
<button type="button" (click)="search.previousHit()">↑</button>
<button type="button" (click)="search.nextHit()">↓</button>
</form>
<epdf-stage>
<ng-template epdfPage>
<epdf-render-layer />
<epdf-search-layer />
</ng-template>
</epdf-stage>
`,
})
export class SearchPage {
protected readonly search = inject(EpdfSearch);
}- Matches appear as they’re found. Large documents are searched a few pages at a time, and the count and the highlights grow as the search goes.
- Searching again replaces the search. Type another letter and the old search stops; an empty
textclears it. - The first match becomes the active one without moving the view.
nextHit(),previousHit()andgoToHit(hit)move to a match and bring it into view, and wrap around at the ends.
Search options#
search.search({ text: 'invoice total', wholeWord: true, matchCase: false });Loading live preview…
import {
ChangeDetectionStrategy,
Component,
effect,
inject,
signal,
ViewEncapsulation,
} from '@angular/core';
import { EpdfDocumentGate, provideEmbedPdf } from '@embedpdf/angular/runtime';
import type { OpenInput } from '@embedpdf/angular/runtime';
import { EpdfPageTemplate, EpdfStage, withStage } from '@embedpdf/angular/stage';
import { EpdfRenderLayer, withRender } from '@embedpdf/angular/render';
import { EpdfSearch, EpdfSearchLayer, withSearch } from '@embedpdf/angular/search';
import type { SearchQuery } from '@embedpdf/angular/search';
import { localEngine } from '@embedpdf/engine';
const ebook = async (): Promise<OpenInput> => {
const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};
@Component({
selector: 'demo-search-with-options',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="toolbar">
<input
#field
class="field"
type="search"
aria-label="Search"
placeholder="Search…"
[value]="query().text"
(input)="change({ text: field.value })"
/>
<label class="option">
<input
#matchCase
type="checkbox"
[checked]="query().matchCase"
(change)="change({ matchCase: matchCase.checked })"
/>
Match case
</label>
<label class="option">
<input
#wholeWord
type="checkbox"
[checked]="query().wholeWord"
(change)="change({ wholeWord: wholeWord.checked })"
/>
Whole words
</label>
<output class="readout">{{ search.hitCount() }} matches</output>
</div>
`,
})
export class SearchWithOptions {
protected readonly search = inject(EpdfSearch);
protected readonly query = signal<SearchQuery>({
text: 'pdf',
matchCase: false,
wholeWord: false,
});
constructor() {
// The text and the options are one query: changing either searches again.
effect(() => {
const query = this.query();
void this.search.search(query);
});
}
protected change(part: Partial<SearchQuery>) {
this.query.update((query) => ({ ...query, ...part }));
}
}
@Component({
selector: 'demo-root',
imports: [
EpdfDocumentGate,
EpdfStage,
EpdfPageTemplate,
EpdfRenderLayer,
EpdfSearchLayer,
SearchWithOptions,
],
providers: [
provideEmbedPdf(
{ engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
withStage(),
withRender(),
withSearch(),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './options.css',
encapsulation: ViewEncapsulation.None,
template: `
<ng-container *epdfDocumentGate="let document; fallback: loading">
<demo-search-with-options />
<epdf-stage class="stage">
<ng-template epdfPage>
<epdf-render-layer />
<epdf-search-layer />
</ng-template>
</epdf-stage>
</ng-container>
<ng-template #loading><p class="loading">Loading…</p></ng-template>
`,
})
export class App {}Try it: turn on Match case and
EmbedPDF.
| Option | What it does | Default |
|---|---|---|
text | What to find | required |
matchCase | Invoice doesn’t match invoice | false |
wholeWord | cat doesn’t match category | false |
matchDiacritics | resume doesn’t match résumé | false |
ignoreWhitespace | invoice also finds i n v o i c e, for text with odd spacing | false |
regex | text is a regular expression | false |
A search starts from the page the reader is on, and { from: page.ref } starts it elsewhere.
A list of results#
search.hits() gives you every match found so far, each with a few words around it:
Loading live preview…
import {
ChangeDetectionStrategy,
Component,
effect,
inject,
signal,
ViewEncapsulation,
} from '@angular/core';
import { EpdfDocumentGate, provideEmbedPdf } from '@embedpdf/angular/runtime';
import type { OpenInput } from '@embedpdf/angular/runtime';
import { EpdfPageTemplate, EpdfStage, withStage } from '@embedpdf/angular/stage';
import { EpdfRenderLayer, withRender } from '@embedpdf/angular/render';
import { EpdfSearch, EpdfSearchLayer, withSearch } from '@embedpdf/angular/search';
import { localEngine } from '@embedpdf/engine';
const ebook = async (): Promise<OpenInput> => {
const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};
@Component({
selector: 'demo-search-box',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="toolbar">
<input
#field
class="field"
type="search"
aria-label="Search"
placeholder="Search…"
[value]="text()"
(input)="text.set(field.value)"
/>
<output class="readout">{{ search.hitCount() }} matches</output>
</div>
`,
})
export class SearchBox {
protected readonly search = inject(EpdfSearch);
protected readonly text = signal('PDF');
constructor() {
effect(() => {
const text = this.text();
void this.search.search({ text });
});
}
}
@Component({
selector: 'demo-root',
imports: [
EpdfDocumentGate,
EpdfStage,
EpdfPageTemplate,
EpdfRenderLayer,
EpdfSearchLayer,
SearchBox,
],
providers: [
provideEmbedPdf(
{ engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
withStage(),
withRender(),
withSearch(),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './results-list.css',
encapsulation: ViewEncapsulation.None,
template: `
<ng-container *epdfDocumentGate="let document; fallback: loading">
<demo-search-box />
<div class="viewer">
<epdf-stage class="stage">
<ng-template epdfPage>
<epdf-render-layer />
<epdf-search-layer />
</ng-template>
</epdf-stage>
<!-- Every match with the words around it. Clicking one makes it the active match and
scrolls to it. -->
<ol class="results">
@for (hit of search.hits(); track hit.page.objectNumber + ':' + hit.start) {
<li>
<button
type="button"
class="result"
[attr.aria-current]="$index === search.activeHitIndex()"
(click)="search.goToHit(hit)"
>
<span class="result-page">Page {{ hit.pageIndex + 1 }}</span>
<!-- A match has no snippet when the user may search but not copy text. -->
@if (hit.snippet; as snippet) {
<span class="result-text">
…{{ snippet.before }}<mark>{{ snippet.match }}</mark
>{{ snippet.after }}…
</span>
}
</button>
</li>
}
</ol>
</div>
</ng-container>
<ng-template #loading><p class="loading">Loading…</p></ng-template>
`,
})
export class App {
protected readonly search = inject(EpdfSearch);
}import { Component, inject } from '@angular/core';
import { EpdfSearch } from '@embedpdf/angular/search';
@Component({
selector: 'app-search-results',
template: `
<ol>
@for (hit of search.hits(); track $index) {
<li>
<button type="button" (click)="search.goToHit(hit)">
Page {{ hit.pageIndex + 1 }}: …{{ hit.snippet?.before }}
<mark>{{ hit.snippet?.match }}</mark>
{{ hit.snippet?.after }}…
</button>
</li>
}
</ol>
`,
})
export class SearchResults {
protected readonly search = inject(EpdfSearch);
}search.hitsOn(page.ref) gives the matches on one page, for a count per thumbnail.
| Field | What it is |
|---|---|
page | The page it’s on |
pageIndex | That page’s position, from 0 |
start, count | The characters it covers: a range you can select or highlight |
bounds | A box around it, in page coordinates |
segments | One piece per line it covers, each with its four corners |
snippet | The words before, the match and the words after |
Highlight or select a match#
A match is a range of characters, the same kind a selection and a highlight use, so it goes straight into either:
const hit = search.getActiveHit();
selection.select(hit); // select it
await annotation.create(hit.page, {
subtype: 'highlight',
quadPoints: hit.segments.map((segment) => segment.quad),
}); // highlight itSearch without showing it#
findAll() searches the whole document and gives you the matches, without touching the search box’s
results or the highlights:
const hits = await search.findAll({ text: 'confidential', wholeWord: true });Use it for a “mark every occurrence” feature, or to redact every match.
Change how matches look#
Set the highlight colors when you register the plugin:
withSearch({
highlight: { color: '#fde047', activeColor: '#f97316' },
});On dark or scanned pages, where a multiplied highlight disappears, use translucent colors and
blendMode: 'normal'. Each of these can also come from CSS, which wins over the setting, for
example in dark mode:
@media (prefers-color-scheme: dark) {
.pdf-viewer {
--epdf-search-highlight: rgb(253 224 71 / 0.4);
--epdf-search-blend-mode: normal;
}
}Loading live preview…
import {
ChangeDetectionStrategy,
Component,
effect,
inject,
signal,
ViewEncapsulation,
} from '@angular/core';
import { EpdfDocument, EpdfDocumentGate, provideEmbedPdf } from '@embedpdf/angular/runtime';
import type { OpenInput } from '@embedpdf/angular/runtime';
import { EpdfPageTemplate, EpdfStage, withStage } from '@embedpdf/angular/stage';
import { EpdfRenderLayer, withRender } from '@embedpdf/angular/render';
import { EpdfSearch, EpdfSearchLayer, withSearch } from '@embedpdf/angular/search';
import { localEngine } from '@embedpdf/engine';
// Each pair: every match, and the active one.
const COLORS = [
{ name: 'Yellow', color: '#ffd500', activeColor: '#ff9632' },
{ name: 'Blue', color: '#a5d8ff', activeColor: '#4c9bff' },
{ name: 'Green', color: '#b2f2bb', activeColor: '#40c057' },
{ name: 'Pink', color: '#fcc2d7', activeColor: '#f06595' },
];
const ebook = async (): Promise<OpenInput> => {
const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};
@Component({
selector: 'demo-root',
imports: [EpdfDocumentGate, EpdfStage, EpdfPageTemplate, EpdfRenderLayer, EpdfSearchLayer],
providers: [
provideEmbedPdf(
{ engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
withStage(),
withRender(),
withSearch({ highlight: { color: '#ffd500', activeColor: '#ff9632' } }),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './highlight-colors.css',
encapsulation: ViewEncapsulation.None,
template: `
<ng-container *epdfDocumentGate="let document; fallback: loading">
<div class="toolbar">
@for (choice of colors; track choice.name) {
<button
type="button"
class="swatch"
[attr.aria-label]="choice.name"
[attr.aria-pressed]="search.settings().highlight.color === choice.color"
[style.background]="
'linear-gradient(135deg, ' + choice.color + ' 50%, ' + choice.activeColor + ' 50%)'
"
(click)="
search.updateSettings({
highlight: { color: choice.color, activeColor: choice.activeColor },
})
"
></button>
}
<button type="button" class="button" (click)="search.resetSettings()">Reset</button>
<label class="option">
<input
#override
type="checkbox"
[checked]="fromCss()"
(change)="fromCss.set(override.checked)"
/>
Override with CSS
</label>
</div>
<epdf-stage class="stage" [class.from-css]="fromCss()">
<ng-template epdfPage>
<epdf-render-layer />
<epdf-search-layer />
</ng-template>
</epdf-stage>
</ng-container>
<ng-template #loading><p class="loading">Loading…</p></ng-template>
`,
})
export class App {
protected readonly colors = COLORS;
protected readonly fromCss = signal(false);
protected readonly search = inject(EpdfSearch);
private readonly document = inject(EpdfDocument);
constructor() {
// Something to highlight, once the document is open.
effect(() => {
if (this.document.status() === 'ready') {
void this.search.search({ text: 'PDF' });
}
});
}
}Try it: pick a color, and
search.updateSettings({ highlight })changes it at once. Then turn on Override with CSS: the CSS variables win over the color you picked, and their translucent highlights, drawn withblendMode: 'normal', stay visible on the dark cover.
Click a match on the page#
Give <epdf-search-layer (hitClick)> a function, and it’s called with the match someone clicks.
goToHit() takes that match as it is, so making it the active one is one line:
import { Component, inject } from '@angular/core';
import { EpdfSearch, EpdfSearchLayer } from '@embedpdf/angular/search';
@Component({
selector: 'app-page-matches',
imports: [EpdfSearchLayer],
template: `<epdf-search-layer (hitClick)="search.goToHit($event)" />`,
})
export class PageMatches {
protected readonly search = inject(EpdfSearch);
}Loading live preview…
import { ChangeDetectionStrategy, Component, inject, ViewEncapsulation } from '@angular/core';
import { EpdfDocumentGate, provideEmbedPdf } from '@embedpdf/angular/runtime';
import type { OpenInput } from '@embedpdf/angular/runtime';
import { EpdfPageTemplate, EpdfStage, withStage } from '@embedpdf/angular/stage';
import { EpdfRenderLayer, withRender } from '@embedpdf/angular/render';
import { EpdfSearch, EpdfSearchLayer, withSearch } from '@embedpdf/angular/search';
import { localEngine } from '@embedpdf/engine';
const ebook = async (): Promise<OpenInput> => {
const response = await fetch('https://snippet.embedpdf.com/ebook.pdf');
return { kind: 'bytes', id: 'ebook', bytes: new Uint8Array(await response.arrayBuffer()) };
};
// Which match is the active one: the first on load, then the one you click.
@Component({
selector: 'demo-active-match',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="toolbar">
<output class="readout">
@if (search.hitCount() > 0) {
Match {{ search.activeHitIndex() + 1 }} of {{ search.hitCount() }}
} @else {
Searching…
}
</output>
<span class="hint">Click a highlighted match to make it the active one.</span>
</div>
`,
})
export class ActiveMatch {
protected readonly search = inject(EpdfSearch);
constructor() {
// It's drawn once the document is open: search right away.
void this.search.search({ text: 'PDF' });
}
}
@Component({
selector: 'demo-root',
imports: [
EpdfDocumentGate,
EpdfStage,
EpdfPageTemplate,
EpdfRenderLayer,
EpdfSearchLayer,
ActiveMatch,
],
providers: [
provideEmbedPdf(
{ engine: () => localEngine(), initialDocuments: [{ source: ebook }] },
withStage(),
withRender(),
withSearch(),
),
],
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './click-match.css',
encapsulation: ViewEncapsulation.None,
template: `
<ng-container *epdfDocumentGate="let document; fallback: loading">
<demo-active-match />
<epdf-stage class="stage">
<ng-template epdfPage>
<epdf-render-layer />
<epdf-search-layer (hitClick)="search.goToHit($event)" />
</ng-template>
</epdf-stage>
</ng-container>
<ng-template #loading><p class="loading">Loading…</p></ng-template>
`,
})
export class App {
protected readonly search = inject(EpdfSearch);
}The text under a match stays selectable: a drag that starts on a match selects text, and only a click calls the function. Without one, matches are only paint, and the pointer goes straight through to the page.
Methods#
Everything inject(EpdfSearch) gives you.
| Method | What it does |
|---|---|
search(query, { from? }) | Starts a search, replacing the one before, and highlights the matches as they’re found. Resolves { status, hitCount } when it’s done, replaced or cancelled. An empty text clears. Search options |
nextHit(reveal?), previousHit(reveal?) | Makes the next or previous match the active one and scrolls to it, wrapping at the ends. Returns the match, or null when there is none. |
goToHit(hit, reveal?) | Makes one match the active one and scrolls to it: the match itself, or its place in the list. Click a match on the page |
revealActiveHit(reveal?) | Scrolls back to the active match without changing it |
listHits({ page? }) | Every match found so far, or the ones on one page. In a component, use search.hits(). |
listPagesWithHits() | The pages that have a match, in document order |
cancel() | Stops searching and keeps the matches found so far |
clear() | Stops searching and removes the matches and their highlights |
refresh() | Runs the current search again from the start. The plugin does this by itself when the document changes. |
findAll(query, { snippets? }) | Finds every match without showing them or touching the current search. snippets: false leaves out the words around each match. Search without showing it |
canSearch({ snippets? }) | Whether searching is allowed; with snippets: true, whether matches carry the words around them. Permissions |
getSettings(), updateSettings(changes), resetSettings() | Reads the settings, changes them while the app runs, or goes back to what you registered. In a component, use inject(EpdfSearch).settings(). Settings |
reveal is { anchor, behavior }, the reveal setting for one call. Every method that
returns a promise also takes a signal in its options, to cancel it.
State#
Every value is a signal on inject(EpdfSearch): read it in a template or a computed(), and your view updates when it changes. The method in the last column reads the same value once.
| Field | What it is | Read once |
|---|---|---|
query() | The last search, with its options, or null | getQuery() |
status() | 'idle', 'searching', 'complete', 'cancelled' or 'error' | getStatus() |
hitCount() | How many matches were found so far | getHitCount(), or getHitCount(page) for one page |
activeHitIndex() | The active match’s place in the list, from 0, or -1 | getActiveHitIndex() |
activeHit() | The active match, or null | getActiveHit() |
progress() | How far the search got: pagesSearched of pageCount | getProgress() |
error() | Why the search failed, when status is 'error' | getError() |
The matches themselves come from search.hits().
Settings#
Set them when you register the plugin, and change them while the app runs with
search.updateSettings({ … }); search.resetSettings() goes back to what you registered, and
inject(EpdfSearch).settings() gives you the current settings.
| Setting | What it does | Default | From CSS |
|---|---|---|---|
reveal.anchor | Where a match lands in the view when you move to it | { y: 0.35 }, like a browser | — |
reveal.behavior | Glide there ('smooth') or jump ('instant') | 'smooth' | — |
highlight.color | The highlight of every match | '#ffd500' | --epdf-search-highlight |
highlight.activeColor | The highlight of the active match | '#ff9632' | --epdf-search-highlight-active |
highlight.blendMode | How the highlights mix with the page | 'multiply' | --epdf-search-blend-mode |
Events#
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { map } from 'rxjs';
import { EpdfSearch } from '@embedpdf/angular/search';
@Component({
selector: 'app-match-count',
template: `<p aria-live="polite">{{ message() }}</p>`,
})
export class MatchCount {
private readonly search = inject(EpdfSearch);
protected readonly message = toSignal(
this.search.completed$.pipe(map(({ hitCount }) => `${hitCount} matches`)),
{ initialValue: '' },
);
}| Event | Fires when | Carries |
|---|---|---|
started$ | A search started | query |
progressChanged$ | The search’s progress changed: more pages were searched, or a new search started over | hitCount, pagesSearched, pageCount |
completed$ | The whole document was searched | hitCount |
cancelled$ | A search stopped before the end | reason |
failed$ | A search failed | error |
activeHitChanged$ | Another match became the active one | index, hit |
cleared$ | The search was cleared | — |
settingsChanged$ | The settings changed | settings, changed |
Permissions#
| Permission | What it allows | Check |
|---|---|---|
doc.text.search | Searching | search.canSearch() |
doc.text.copy | The words around each match (snippet) | search.canSearch({ snippets: true }) |
Without doc.text.copy, search still finds and highlights every match, but a match has no
snippet.
Good to know#
Searches stay up to date#
When the document changes, for example when pages are inserted or text is redacted, the current search runs again, so the matches and the count stay right.
Searching large documents#
The local engine searches the file in the browser. Big documents are searched a few pages at a time, so the page stays responsive and results arrive as they’re found.
Your feedback goes directly to the documentation team.