@embedpdf/plugin-search 3.0.0-next.1 → 3.0.0-next.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ let _embedpdf_core = require("@embedpdf/core");
2
+ require("@embedpdf/engine-core/runtime");
3
+ //#region src/types.ts
4
+ const SearchToken = (0, _embedpdf_core.createCapabilityToken)("search");
5
+ //#endregion
6
+ Object.defineProperty(exports, "SearchToken", {
7
+ enumerable: true,
8
+ get: function() {
9
+ return SearchToken;
10
+ }
11
+ });
12
+
13
+ //# sourceMappingURL=contract-4PZsI7Ty.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"contract-4PZsI7Ty.cjs","names":[],"sources":["../src/types.ts"],"sourcesContent":["import { createCapabilityToken, type PageObjectNumber } from '@embedpdf/core';\nimport type { Rect, TextQuad } from '@embedpdf/core-geometry';\nimport type { RevealAnchor, ScrollBehaviorKind } from '@embedpdf/plugin-stage/contract';\nimport type { SearchMode, SearchQuery, SearchSnippet } from '@embedpdf/engine-core/runtime';\n\n/**\n * One match, viewer-shaped: the engine's PDF-space line rects converted to\n * CONTENT space (y-down — the same space selection rects live in), plus the\n * page's display index so navigation can `reveal()` without a lookup.\n * `charStart`/`charCount` keep the engine's text-page offsets, so a hit can\n * later seed a selection or a highlight annotation without re-searching.\n */\n/**\n * One merged visual line of a match, in content space — the structural twin\n * of selection's `SelectionSegment` and the engine's `PdfTextSegment`.\n * `quad` is the geometric authority (corner-named, frame-geometric); `rect`\n * its AABB; `advance` the reading direction along the baseline.\n */\nexport interface TextSegment {\n quad: TextQuad;\n rect: Rect;\n advance: 1 | -1;\n}\n\nexport interface SearchHit {\n pon: PageObjectNumber;\n pageIndex: number;\n charStart: number;\n charCount: number;\n /** Canonical visual-line segments (content space) — the drawing input. */\n segments: TextSegment[];\n /** Union envelope of the segments — the scroll/reveal target. Absent when\n * a match carries no drawable geometry (degenerate glyphs only). */\n bounds?: Rect;\n snippet?: SearchSnippet;\n}\n\n/**\n * `searching` means the slice loop is still walking pages — results are\n * usable the moment they appear (matches stream in viewport-first order).\n */\nexport type SearchStatus = 'idle' | 'searching' | 'complete' | 'error';\n\n/**\n * Execution options for the session `search()` — HOW the scan runs, never\n * part of what the query MEANS (which is why they're not on `SearchQuery`\n * and are never stored or restored into a search box).\n */\nexport interface SearchExecOptions {\n /** Scan origin override. Defaults to the Stage's current page (viewport-first). */\n startPage?: PageObjectNumber;\n}\n\n/** Options for the session-free `findAll()` service. */\nexport interface SearchFindAllOptions {\n /** Abort the scan; the returned promise rejects with `signal.reason`. */\n signal?: AbortSignal;\n /**\n * Pin the slice mode. Default: `'full'` with automatic `'rects'`\n * fallback when snippets are denied — pass `'rects'` when you only need\n * geometry (e.g. redact-by-term) to skip snippet extraction entirely.\n */\n mode?: SearchMode;\n}\n\nexport interface SearchState {\n /**\n * The query in flight/completed, null when idle/cleared — THE stored\n * form of the search intent, the same flat `SearchQuery` the engine\n * matches on, the wire caches by, and a search box renders. Survives\n * any search UI unmounting (results are document-scoped; so is this).\n */\n query: SearchQuery | null;\n status: SearchStatus;\n hits: SearchHit[];\n /** pon → indices into `hits` — the per-page layer's read model. */\n hitsByPage: Record<number, number[]>;\n /** Index into `hits`, -1 = none. */\n activeIndex: number;\n progress: { scanned: number; total: number };\n error: string | null;\n}\n\nexport type SearchAction =\n | { type: 'START'; query: SearchQuery }\n | { type: 'APPEND'; hits: SearchHit[]; scanned: number; total: number }\n | { type: 'COMPLETE' }\n | { type: 'ERROR'; message: string }\n | { type: 'SET_ACTIVE'; index: number }\n | { type: 'CLEAR' };\n\n/**\n * How hit navigation arrives (forwarded to the Stage's positioned reveal).\n * The default is the browser find-bar feel: the hit lands at the top-middle\n * of the viewport (`anchor: { y: 0.35 }`), horizontal only moves when the\n * hit is off-screen, zoom never changes, smooth tween.\n */\nexport interface SearchRevealOptions {\n anchor?: RevealAnchor;\n behavior?: ScrollBehaviorKind;\n}\n\nexport interface SearchPluginConfig {\n /** Arrival defaults for `next()`/`prev()`/`goTo()`. */\n reveal?: SearchRevealOptions;\n}\n\n/**\n * The search plugin is a find SERVICE plus one user-visible search SESSION\n * per document. `findAll` is the service; `search` is the session; the\n * sidebar, the highlights layer, and next/prev render the session.\n */\nexport interface SearchCapability {\n /**\n * The twin (permissions.md): would a search succeed now for this session?\n * No `mode` (or `'rects'`) asks about finding at all — `doc.text.search`\n * — which is what `search()` needs (the session degrades `'full'` to\n * `'rects'` by itself when snippets are denied). `canSearch('full')` also\n * requires `doc.text.copy` (a snippet reproduces document text) — ask it\n * before advertising snippet UI or pinning `mode: 'full'` in `findAll`.\n */\n canSearch(mode?: SearchMode): boolean;\n\n // ── the session: THE user-visible find, one per document ────────────────\n /**\n * Start a new search (supersedes and aborts any running one). Results\n * stream into state slice by slice; the first hit becomes active but the\n * camera does NOT move until `next()`/`prev()`/`goTo()`. An empty\n * `query.text` is \"stop searching\" — identical to `clear()`.\n */\n search(query: SearchQuery, exec?: SearchExecOptions): void;\n /** Re-run the current query from scratch (used after document mutations). */\n rerun(): void;\n clear(): void;\n\n /** Advance to the next/previous hit (wraps) and reveal it (see {@link SearchRevealOptions}). */\n next(): SearchHit | null;\n prev(): SearchHit | null;\n /** Jump to a specific hit index and reveal it; `reveal` overrides the plugin defaults. */\n goTo(index: number, reveal?: SearchRevealOptions): SearchHit | null;\n\n /** The session's query — what a search box renders. Null when idle. */\n query(): SearchQuery | null;\n status(): SearchStatus;\n hits(): SearchHit[];\n hitCount(): number;\n activeIndex(): number;\n activeHit(): SearchHit | null;\n /** Hits on one page — the layer's input. Element identity is stable. */\n hitsForPage(pon: PageObjectNumber): SearchHit[];\n progress(): { scanned: number; total: number };\n errorMessage(): string | null;\n\n // ── the mechanism: session-free find service ─────────────────────────────\n /**\n * Run a query to completion and return every hit. Touches NO state — no\n * sidebar, no highlights, no camera, no activeIndex; the user's visible\n * search is never superseded. The primitive other features build on\n * (redact-by-term, link detection, occurrence badges). Scans in natural\n * page order; concurrent calls are independent.\n */\n findAll(query: SearchQuery, opts?: SearchFindAllOptions): Promise<SearchHit[]>;\n}\n\nexport const SearchToken = createCapabilityToken<SearchCapability>('search');\n"],"mappings":";;;AAoKA,MAAa,eAAA,GAAA,eAAA,sBAAA,CAAsD,QAAQ"}
@@ -0,0 +1,170 @@
1
+ import { PageObjectNumber } from "@embedpdf/core";
2
+ import { Rect, TextQuad } from "@embedpdf/core-geometry";
3
+ import { RevealAnchor, ScrollBehaviorKind } from "@embedpdf/plugin-stage/contract";
4
+ import { SearchMode, SearchQuery, SearchQuery as SearchQuery$1, SearchQueryIssue, SearchQueryValidation, SearchRegexValidation, SearchSnippet, SearchSnippet as SearchSnippet$1, validateSearchQuery, validateSearchRegex } from "@embedpdf/engine-core/runtime";
5
+ //#region src/types.d.ts
6
+ /**
7
+ * One match, viewer-shaped: the engine's PDF-space line rects converted to
8
+ * CONTENT space (y-down — the same space selection rects live in), plus the
9
+ * page's display index so navigation can `reveal()` without a lookup.
10
+ * `charStart`/`charCount` keep the engine's text-page offsets, so a hit can
11
+ * later seed a selection or a highlight annotation without re-searching.
12
+ */
13
+ /**
14
+ * One merged visual line of a match, in content space — the structural twin
15
+ * of selection's `SelectionSegment` and the engine's `PdfTextSegment`.
16
+ * `quad` is the geometric authority (corner-named, frame-geometric); `rect`
17
+ * its AABB; `advance` the reading direction along the baseline.
18
+ */
19
+ interface TextSegment {
20
+ quad: TextQuad;
21
+ rect: Rect;
22
+ advance: 1 | -1;
23
+ }
24
+ interface SearchHit {
25
+ pon: PageObjectNumber;
26
+ pageIndex: number;
27
+ charStart: number;
28
+ charCount: number;
29
+ /** Canonical visual-line segments (content space) — the drawing input. */
30
+ segments: TextSegment[];
31
+ /** Union envelope of the segments — the scroll/reveal target. Absent when
32
+ * a match carries no drawable geometry (degenerate glyphs only). */
33
+ bounds?: Rect;
34
+ snippet?: SearchSnippet;
35
+ }
36
+ /**
37
+ * `searching` means the slice loop is still walking pages — results are
38
+ * usable the moment they appear (matches stream in viewport-first order).
39
+ */
40
+ type SearchStatus = 'idle' | 'searching' | 'complete' | 'error';
41
+ /**
42
+ * Execution options for the session `search()` — HOW the scan runs, never
43
+ * part of what the query MEANS (which is why they're not on `SearchQuery`
44
+ * and are never stored or restored into a search box).
45
+ */
46
+ interface SearchExecOptions {
47
+ /** Scan origin override. Defaults to the Stage's current page (viewport-first). */
48
+ startPage?: PageObjectNumber;
49
+ }
50
+ /** Options for the session-free `findAll()` service. */
51
+ interface SearchFindAllOptions {
52
+ /** Abort the scan; the returned promise rejects with `signal.reason`. */
53
+ signal?: AbortSignal;
54
+ /**
55
+ * Pin the slice mode. Default: `'full'` with automatic `'rects'`
56
+ * fallback when snippets are denied — pass `'rects'` when you only need
57
+ * geometry (e.g. redact-by-term) to skip snippet extraction entirely.
58
+ */
59
+ mode?: SearchMode;
60
+ }
61
+ interface SearchState {
62
+ /**
63
+ * The query in flight/completed, null when idle/cleared — THE stored
64
+ * form of the search intent, the same flat `SearchQuery` the engine
65
+ * matches on, the wire caches by, and a search box renders. Survives
66
+ * any search UI unmounting (results are document-scoped; so is this).
67
+ */
68
+ query: SearchQuery | null;
69
+ status: SearchStatus;
70
+ hits: SearchHit[];
71
+ /** pon → indices into `hits` — the per-page layer's read model. */
72
+ hitsByPage: Record<number, number[]>;
73
+ /** Index into `hits`, -1 = none. */
74
+ activeIndex: number;
75
+ progress: {
76
+ scanned: number;
77
+ total: number;
78
+ };
79
+ error: string | null;
80
+ }
81
+ type SearchAction = {
82
+ type: 'START';
83
+ query: SearchQuery;
84
+ } | {
85
+ type: 'APPEND';
86
+ hits: SearchHit[];
87
+ scanned: number;
88
+ total: number;
89
+ } | {
90
+ type: 'COMPLETE';
91
+ } | {
92
+ type: 'ERROR';
93
+ message: string;
94
+ } | {
95
+ type: 'SET_ACTIVE';
96
+ index: number;
97
+ } | {
98
+ type: 'CLEAR';
99
+ };
100
+ /**
101
+ * How hit navigation arrives (forwarded to the Stage's positioned reveal).
102
+ * The default is the browser find-bar feel: the hit lands at the top-middle
103
+ * of the viewport (`anchor: { y: 0.35 }`), horizontal only moves when the
104
+ * hit is off-screen, zoom never changes, smooth tween.
105
+ */
106
+ interface SearchRevealOptions {
107
+ anchor?: RevealAnchor;
108
+ behavior?: ScrollBehaviorKind;
109
+ }
110
+ interface SearchPluginConfig {
111
+ /** Arrival defaults for `next()`/`prev()`/`goTo()`. */
112
+ reveal?: SearchRevealOptions;
113
+ }
114
+ /**
115
+ * The search plugin is a find SERVICE plus one user-visible search SESSION
116
+ * per document. `findAll` is the service; `search` is the session; the
117
+ * sidebar, the highlights layer, and next/prev render the session.
118
+ */
119
+ interface SearchCapability {
120
+ /**
121
+ * The twin (permissions.md): would a search succeed now for this session?
122
+ * No `mode` (or `'rects'`) asks about finding at all — `doc.text.search`
123
+ * — which is what `search()` needs (the session degrades `'full'` to
124
+ * `'rects'` by itself when snippets are denied). `canSearch('full')` also
125
+ * requires `doc.text.copy` (a snippet reproduces document text) — ask it
126
+ * before advertising snippet UI or pinning `mode: 'full'` in `findAll`.
127
+ */
128
+ canSearch(mode?: SearchMode): boolean;
129
+ /**
130
+ * Start a new search (supersedes and aborts any running one). Results
131
+ * stream into state slice by slice; the first hit becomes active but the
132
+ * camera does NOT move until `next()`/`prev()`/`goTo()`. An empty
133
+ * `query.text` is "stop searching" — identical to `clear()`.
134
+ */
135
+ search(query: SearchQuery, exec?: SearchExecOptions): void;
136
+ /** Re-run the current query from scratch (used after document mutations). */
137
+ rerun(): void;
138
+ clear(): void;
139
+ /** Advance to the next/previous hit (wraps) and reveal it (see {@link SearchRevealOptions}). */
140
+ next(): SearchHit | null;
141
+ prev(): SearchHit | null;
142
+ /** Jump to a specific hit index and reveal it; `reveal` overrides the plugin defaults. */
143
+ goTo(index: number, reveal?: SearchRevealOptions): SearchHit | null;
144
+ /** The session's query — what a search box renders. Null when idle. */
145
+ query(): SearchQuery | null;
146
+ status(): SearchStatus;
147
+ hits(): SearchHit[];
148
+ hitCount(): number;
149
+ activeIndex(): number;
150
+ activeHit(): SearchHit | null;
151
+ /** Hits on one page — the layer's input. Element identity is stable. */
152
+ hitsForPage(pon: PageObjectNumber): SearchHit[];
153
+ progress(): {
154
+ scanned: number;
155
+ total: number;
156
+ };
157
+ errorMessage(): string | null;
158
+ /**
159
+ * Run a query to completion and return every hit. Touches NO state — no
160
+ * sidebar, no highlights, no camera, no activeIndex; the user's visible
161
+ * search is never superseded. The primitive other features build on
162
+ * (redact-by-term, link detection, occurrence badges). Scans in natural
163
+ * page order; concurrent calls are independent.
164
+ */
165
+ findAll(query: SearchQuery, opts?: SearchFindAllOptions): Promise<SearchHit[]>;
166
+ }
167
+ declare const SearchToken: import("@embedpdf/core").CapabilityToken<SearchCapability>;
168
+ //#endregion
169
+ export { SearchToken as _, SearchSnippet$1 as a, SearchAction as c, SearchFindAllOptions as d, SearchHit as f, SearchStatus as g, SearchState as h, SearchRegexValidation as i, SearchCapability as l, SearchRevealOptions as m, SearchQueryIssue as n, validateSearchQuery as o, SearchPluginConfig as p, SearchQueryValidation as r, validateSearchRegex as s, SearchQuery$1 as t, SearchExecOptions as u };
170
+ //# sourceMappingURL=contract-CSb66POC.d.cts.map
@@ -0,0 +1,170 @@
1
+ import { PageObjectNumber } from "@embedpdf/core";
2
+ import { RevealAnchor, ScrollBehaviorKind } from "@embedpdf/plugin-stage/contract";
3
+ import { Rect, TextQuad } from "@embedpdf/core-geometry";
4
+ import { SearchMode, SearchQuery, SearchQuery as SearchQuery$1, SearchQueryIssue, SearchQueryValidation, SearchRegexValidation, SearchSnippet, SearchSnippet as SearchSnippet$1, validateSearchQuery, validateSearchRegex } from "@embedpdf/engine-core/runtime";
5
+ //#region src/types.d.ts
6
+ /**
7
+ * One match, viewer-shaped: the engine's PDF-space line rects converted to
8
+ * CONTENT space (y-down — the same space selection rects live in), plus the
9
+ * page's display index so navigation can `reveal()` without a lookup.
10
+ * `charStart`/`charCount` keep the engine's text-page offsets, so a hit can
11
+ * later seed a selection or a highlight annotation without re-searching.
12
+ */
13
+ /**
14
+ * One merged visual line of a match, in content space — the structural twin
15
+ * of selection's `SelectionSegment` and the engine's `PdfTextSegment`.
16
+ * `quad` is the geometric authority (corner-named, frame-geometric); `rect`
17
+ * its AABB; `advance` the reading direction along the baseline.
18
+ */
19
+ interface TextSegment {
20
+ quad: TextQuad;
21
+ rect: Rect;
22
+ advance: 1 | -1;
23
+ }
24
+ interface SearchHit {
25
+ pon: PageObjectNumber;
26
+ pageIndex: number;
27
+ charStart: number;
28
+ charCount: number;
29
+ /** Canonical visual-line segments (content space) — the drawing input. */
30
+ segments: TextSegment[];
31
+ /** Union envelope of the segments — the scroll/reveal target. Absent when
32
+ * a match carries no drawable geometry (degenerate glyphs only). */
33
+ bounds?: Rect;
34
+ snippet?: SearchSnippet;
35
+ }
36
+ /**
37
+ * `searching` means the slice loop is still walking pages — results are
38
+ * usable the moment they appear (matches stream in viewport-first order).
39
+ */
40
+ type SearchStatus = 'idle' | 'searching' | 'complete' | 'error';
41
+ /**
42
+ * Execution options for the session `search()` — HOW the scan runs, never
43
+ * part of what the query MEANS (which is why they're not on `SearchQuery`
44
+ * and are never stored or restored into a search box).
45
+ */
46
+ interface SearchExecOptions {
47
+ /** Scan origin override. Defaults to the Stage's current page (viewport-first). */
48
+ startPage?: PageObjectNumber;
49
+ }
50
+ /** Options for the session-free `findAll()` service. */
51
+ interface SearchFindAllOptions {
52
+ /** Abort the scan; the returned promise rejects with `signal.reason`. */
53
+ signal?: AbortSignal;
54
+ /**
55
+ * Pin the slice mode. Default: `'full'` with automatic `'rects'`
56
+ * fallback when snippets are denied — pass `'rects'` when you only need
57
+ * geometry (e.g. redact-by-term) to skip snippet extraction entirely.
58
+ */
59
+ mode?: SearchMode;
60
+ }
61
+ interface SearchState {
62
+ /**
63
+ * The query in flight/completed, null when idle/cleared — THE stored
64
+ * form of the search intent, the same flat `SearchQuery` the engine
65
+ * matches on, the wire caches by, and a search box renders. Survives
66
+ * any search UI unmounting (results are document-scoped; so is this).
67
+ */
68
+ query: SearchQuery | null;
69
+ status: SearchStatus;
70
+ hits: SearchHit[];
71
+ /** pon → indices into `hits` — the per-page layer's read model. */
72
+ hitsByPage: Record<number, number[]>;
73
+ /** Index into `hits`, -1 = none. */
74
+ activeIndex: number;
75
+ progress: {
76
+ scanned: number;
77
+ total: number;
78
+ };
79
+ error: string | null;
80
+ }
81
+ type SearchAction = {
82
+ type: 'START';
83
+ query: SearchQuery;
84
+ } | {
85
+ type: 'APPEND';
86
+ hits: SearchHit[];
87
+ scanned: number;
88
+ total: number;
89
+ } | {
90
+ type: 'COMPLETE';
91
+ } | {
92
+ type: 'ERROR';
93
+ message: string;
94
+ } | {
95
+ type: 'SET_ACTIVE';
96
+ index: number;
97
+ } | {
98
+ type: 'CLEAR';
99
+ };
100
+ /**
101
+ * How hit navigation arrives (forwarded to the Stage's positioned reveal).
102
+ * The default is the browser find-bar feel: the hit lands at the top-middle
103
+ * of the viewport (`anchor: { y: 0.35 }`), horizontal only moves when the
104
+ * hit is off-screen, zoom never changes, smooth tween.
105
+ */
106
+ interface SearchRevealOptions {
107
+ anchor?: RevealAnchor;
108
+ behavior?: ScrollBehaviorKind;
109
+ }
110
+ interface SearchPluginConfig {
111
+ /** Arrival defaults for `next()`/`prev()`/`goTo()`. */
112
+ reveal?: SearchRevealOptions;
113
+ }
114
+ /**
115
+ * The search plugin is a find SERVICE plus one user-visible search SESSION
116
+ * per document. `findAll` is the service; `search` is the session; the
117
+ * sidebar, the highlights layer, and next/prev render the session.
118
+ */
119
+ interface SearchCapability {
120
+ /**
121
+ * The twin (permissions.md): would a search succeed now for this session?
122
+ * No `mode` (or `'rects'`) asks about finding at all — `doc.text.search`
123
+ * — which is what `search()` needs (the session degrades `'full'` to
124
+ * `'rects'` by itself when snippets are denied). `canSearch('full')` also
125
+ * requires `doc.text.copy` (a snippet reproduces document text) — ask it
126
+ * before advertising snippet UI or pinning `mode: 'full'` in `findAll`.
127
+ */
128
+ canSearch(mode?: SearchMode): boolean;
129
+ /**
130
+ * Start a new search (supersedes and aborts any running one). Results
131
+ * stream into state slice by slice; the first hit becomes active but the
132
+ * camera does NOT move until `next()`/`prev()`/`goTo()`. An empty
133
+ * `query.text` is "stop searching" — identical to `clear()`.
134
+ */
135
+ search(query: SearchQuery, exec?: SearchExecOptions): void;
136
+ /** Re-run the current query from scratch (used after document mutations). */
137
+ rerun(): void;
138
+ clear(): void;
139
+ /** Advance to the next/previous hit (wraps) and reveal it (see {@link SearchRevealOptions}). */
140
+ next(): SearchHit | null;
141
+ prev(): SearchHit | null;
142
+ /** Jump to a specific hit index and reveal it; `reveal` overrides the plugin defaults. */
143
+ goTo(index: number, reveal?: SearchRevealOptions): SearchHit | null;
144
+ /** The session's query — what a search box renders. Null when idle. */
145
+ query(): SearchQuery | null;
146
+ status(): SearchStatus;
147
+ hits(): SearchHit[];
148
+ hitCount(): number;
149
+ activeIndex(): number;
150
+ activeHit(): SearchHit | null;
151
+ /** Hits on one page — the layer's input. Element identity is stable. */
152
+ hitsForPage(pon: PageObjectNumber): SearchHit[];
153
+ progress(): {
154
+ scanned: number;
155
+ total: number;
156
+ };
157
+ errorMessage(): string | null;
158
+ /**
159
+ * Run a query to completion and return every hit. Touches NO state — no
160
+ * sidebar, no highlights, no camera, no activeIndex; the user's visible
161
+ * search is never superseded. The primitive other features build on
162
+ * (redact-by-term, link detection, occurrence badges). Scans in natural
163
+ * page order; concurrent calls are independent.
164
+ */
165
+ findAll(query: SearchQuery, opts?: SearchFindAllOptions): Promise<SearchHit[]>;
166
+ }
167
+ declare const SearchToken: import("@embedpdf/core").CapabilityToken<SearchCapability>;
168
+ //#endregion
169
+ export { SearchToken as _, SearchSnippet$1 as a, SearchAction as c, SearchFindAllOptions as d, SearchHit as f, SearchStatus as g, SearchState as h, SearchRegexValidation as i, SearchCapability as l, SearchRevealOptions as m, SearchQueryIssue as n, validateSearchQuery as o, SearchPluginConfig as p, SearchQueryValidation as r, validateSearchRegex as s, SearchQuery$1 as t, SearchExecOptions as u };
170
+ //# sourceMappingURL=contract-Cj4xgdMj.d.ts.map
@@ -0,0 +1,8 @@
1
+ import { createCapabilityToken } from "@embedpdf/core";
2
+ import { validateSearchQuery, validateSearchRegex } from "@embedpdf/engine-core/runtime";
3
+ //#region src/types.ts
4
+ const SearchToken = createCapabilityToken("search");
5
+ //#endregion
6
+ export { validateSearchRegex as n, SearchToken as r, validateSearchQuery as t };
7
+
8
+ //# sourceMappingURL=contract-YzH14CUn.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"contract-YzH14CUn.js","names":[],"sources":["../src/types.ts"],"sourcesContent":["import { createCapabilityToken, type PageObjectNumber } from '@embedpdf/core';\nimport type { Rect, TextQuad } from '@embedpdf/core-geometry';\nimport type { RevealAnchor, ScrollBehaviorKind } from '@embedpdf/plugin-stage/contract';\nimport type { SearchMode, SearchQuery, SearchSnippet } from '@embedpdf/engine-core/runtime';\n\n/**\n * One match, viewer-shaped: the engine's PDF-space line rects converted to\n * CONTENT space (y-down — the same space selection rects live in), plus the\n * page's display index so navigation can `reveal()` without a lookup.\n * `charStart`/`charCount` keep the engine's text-page offsets, so a hit can\n * later seed a selection or a highlight annotation without re-searching.\n */\n/**\n * One merged visual line of a match, in content space — the structural twin\n * of selection's `SelectionSegment` and the engine's `PdfTextSegment`.\n * `quad` is the geometric authority (corner-named, frame-geometric); `rect`\n * its AABB; `advance` the reading direction along the baseline.\n */\nexport interface TextSegment {\n quad: TextQuad;\n rect: Rect;\n advance: 1 | -1;\n}\n\nexport interface SearchHit {\n pon: PageObjectNumber;\n pageIndex: number;\n charStart: number;\n charCount: number;\n /** Canonical visual-line segments (content space) — the drawing input. */\n segments: TextSegment[];\n /** Union envelope of the segments — the scroll/reveal target. Absent when\n * a match carries no drawable geometry (degenerate glyphs only). */\n bounds?: Rect;\n snippet?: SearchSnippet;\n}\n\n/**\n * `searching` means the slice loop is still walking pages — results are\n * usable the moment they appear (matches stream in viewport-first order).\n */\nexport type SearchStatus = 'idle' | 'searching' | 'complete' | 'error';\n\n/**\n * Execution options for the session `search()` — HOW the scan runs, never\n * part of what the query MEANS (which is why they're not on `SearchQuery`\n * and are never stored or restored into a search box).\n */\nexport interface SearchExecOptions {\n /** Scan origin override. Defaults to the Stage's current page (viewport-first). */\n startPage?: PageObjectNumber;\n}\n\n/** Options for the session-free `findAll()` service. */\nexport interface SearchFindAllOptions {\n /** Abort the scan; the returned promise rejects with `signal.reason`. */\n signal?: AbortSignal;\n /**\n * Pin the slice mode. Default: `'full'` with automatic `'rects'`\n * fallback when snippets are denied — pass `'rects'` when you only need\n * geometry (e.g. redact-by-term) to skip snippet extraction entirely.\n */\n mode?: SearchMode;\n}\n\nexport interface SearchState {\n /**\n * The query in flight/completed, null when idle/cleared — THE stored\n * form of the search intent, the same flat `SearchQuery` the engine\n * matches on, the wire caches by, and a search box renders. Survives\n * any search UI unmounting (results are document-scoped; so is this).\n */\n query: SearchQuery | null;\n status: SearchStatus;\n hits: SearchHit[];\n /** pon → indices into `hits` — the per-page layer's read model. */\n hitsByPage: Record<number, number[]>;\n /** Index into `hits`, -1 = none. */\n activeIndex: number;\n progress: { scanned: number; total: number };\n error: string | null;\n}\n\nexport type SearchAction =\n | { type: 'START'; query: SearchQuery }\n | { type: 'APPEND'; hits: SearchHit[]; scanned: number; total: number }\n | { type: 'COMPLETE' }\n | { type: 'ERROR'; message: string }\n | { type: 'SET_ACTIVE'; index: number }\n | { type: 'CLEAR' };\n\n/**\n * How hit navigation arrives (forwarded to the Stage's positioned reveal).\n * The default is the browser find-bar feel: the hit lands at the top-middle\n * of the viewport (`anchor: { y: 0.35 }`), horizontal only moves when the\n * hit is off-screen, zoom never changes, smooth tween.\n */\nexport interface SearchRevealOptions {\n anchor?: RevealAnchor;\n behavior?: ScrollBehaviorKind;\n}\n\nexport interface SearchPluginConfig {\n /** Arrival defaults for `next()`/`prev()`/`goTo()`. */\n reveal?: SearchRevealOptions;\n}\n\n/**\n * The search plugin is a find SERVICE plus one user-visible search SESSION\n * per document. `findAll` is the service; `search` is the session; the\n * sidebar, the highlights layer, and next/prev render the session.\n */\nexport interface SearchCapability {\n /**\n * The twin (permissions.md): would a search succeed now for this session?\n * No `mode` (or `'rects'`) asks about finding at all — `doc.text.search`\n * — which is what `search()` needs (the session degrades `'full'` to\n * `'rects'` by itself when snippets are denied). `canSearch('full')` also\n * requires `doc.text.copy` (a snippet reproduces document text) — ask it\n * before advertising snippet UI or pinning `mode: 'full'` in `findAll`.\n */\n canSearch(mode?: SearchMode): boolean;\n\n // ── the session: THE user-visible find, one per document ────────────────\n /**\n * Start a new search (supersedes and aborts any running one). Results\n * stream into state slice by slice; the first hit becomes active but the\n * camera does NOT move until `next()`/`prev()`/`goTo()`. An empty\n * `query.text` is \"stop searching\" — identical to `clear()`.\n */\n search(query: SearchQuery, exec?: SearchExecOptions): void;\n /** Re-run the current query from scratch (used after document mutations). */\n rerun(): void;\n clear(): void;\n\n /** Advance to the next/previous hit (wraps) and reveal it (see {@link SearchRevealOptions}). */\n next(): SearchHit | null;\n prev(): SearchHit | null;\n /** Jump to a specific hit index and reveal it; `reveal` overrides the plugin defaults. */\n goTo(index: number, reveal?: SearchRevealOptions): SearchHit | null;\n\n /** The session's query — what a search box renders. Null when idle. */\n query(): SearchQuery | null;\n status(): SearchStatus;\n hits(): SearchHit[];\n hitCount(): number;\n activeIndex(): number;\n activeHit(): SearchHit | null;\n /** Hits on one page — the layer's input. Element identity is stable. */\n hitsForPage(pon: PageObjectNumber): SearchHit[];\n progress(): { scanned: number; total: number };\n errorMessage(): string | null;\n\n // ── the mechanism: session-free find service ─────────────────────────────\n /**\n * Run a query to completion and return every hit. Touches NO state — no\n * sidebar, no highlights, no camera, no activeIndex; the user's visible\n * search is never superseded. The primitive other features build on\n * (redact-by-term, link detection, occurrence badges). Scans in natural\n * page order; concurrent calls are independent.\n */\n findAll(query: SearchQuery, opts?: SearchFindAllOptions): Promise<SearchHit[]>;\n}\n\nexport const SearchToken = createCapabilityToken<SearchCapability>('search');\n"],"mappings":";;;AAoKA,MAAa,cAAc,sBAAwC,QAAQ"}
@@ -0,0 +1,16 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_contract = require("./contract-4PZsI7Ty.cjs");
3
+ let _embedpdf_engine_core_runtime = require("@embedpdf/engine-core/runtime");
4
+ exports.SearchToken = require_contract.SearchToken;
5
+ Object.defineProperty(exports, "validateSearchQuery", {
6
+ enumerable: true,
7
+ get: function() {
8
+ return _embedpdf_engine_core_runtime.validateSearchQuery;
9
+ }
10
+ });
11
+ Object.defineProperty(exports, "validateSearchRegex", {
12
+ enumerable: true,
13
+ get: function() {
14
+ return _embedpdf_engine_core_runtime.validateSearchRegex;
15
+ }
16
+ });
@@ -0,0 +1,2 @@
1
+ import { _ as SearchToken, a as SearchSnippet, d as SearchFindAllOptions, f as SearchHit, g as SearchStatus, h as SearchState, i as SearchRegexValidation, l as SearchCapability, m as SearchRevealOptions, n as SearchQueryIssue, o as validateSearchQuery, p as SearchPluginConfig, r as SearchQueryValidation, s as validateSearchRegex, t as SearchQuery, u as SearchExecOptions } from "./contract-CSb66POC.cjs";
2
+ export { type SearchCapability, type SearchExecOptions, type SearchFindAllOptions, type SearchHit, type SearchPluginConfig, type SearchQuery, type SearchQueryIssue, type SearchQueryValidation, type SearchRegexValidation, type SearchRevealOptions, type SearchSnippet, type SearchState, type SearchStatus, SearchToken, validateSearchQuery, validateSearchRegex };
@@ -0,0 +1,2 @@
1
+ import { _ as SearchToken, a as SearchSnippet, d as SearchFindAllOptions, f as SearchHit, g as SearchStatus, h as SearchState, i as SearchRegexValidation, l as SearchCapability, m as SearchRevealOptions, n as SearchQueryIssue, o as validateSearchQuery, p as SearchPluginConfig, r as SearchQueryValidation, s as validateSearchRegex, t as SearchQuery, u as SearchExecOptions } from "./contract-Cj4xgdMj.js";
2
+ export { type SearchCapability, type SearchExecOptions, type SearchFindAllOptions, type SearchHit, type SearchPluginConfig, type SearchQuery, type SearchQueryIssue, type SearchQueryValidation, type SearchRegexValidation, type SearchRevealOptions, type SearchSnippet, type SearchState, type SearchStatus, SearchToken, validateSearchQuery, validateSearchRegex };
@@ -0,0 +1,2 @@
1
+ import { n as validateSearchRegex, r as SearchToken, t as validateSearchQuery } from "./contract-YzH14CUn.js";
2
+ export { SearchToken, validateSearchQuery, validateSearchRegex };
package/dist/index.cjs CHANGED
@@ -1,6 +1,7 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_contract = require("./contract-4PZsI7Ty.cjs");
2
3
  let _embedpdf_core = require("@embedpdf/core");
3
- let _embedpdf_plugin_stage = require("@embedpdf/plugin-stage");
4
+ let _embedpdf_plugin_stage_contract = require("@embedpdf/plugin-stage/contract");
4
5
  let _embedpdf_core_geometry = require("@embedpdf/core-geometry");
5
6
  let _embedpdf_engine_core_runtime = require("@embedpdf/engine-core/runtime");
6
7
  //#region src/capability.ts
@@ -30,12 +31,6 @@ const isPermissionDenied = (err) => err instanceof Error && err.name === "Permis
30
31
  function createSearchCapability(ctx, config = {}) {
31
32
  let generation = 0;
32
33
  let inflight = null;
33
- const toContent = (m, b) => (0, _embedpdf_core_geometry.applyRect)(m, {
34
- x: b.left,
35
- y: b.bottom,
36
- width: b.right - b.left,
37
- height: b.top - b.bottom
38
- });
39
34
  function hitsFromSlice(slice) {
40
35
  const converters = /* @__PURE__ */ new Map();
41
36
  const converterFor = (pon) => {
@@ -49,9 +44,15 @@ function createSearchCapability(ctx, config = {}) {
49
44
  rotation: layout.rotation,
50
45
  userUnit: layout.userUnit
51
46
  }, 1);
47
+ const toPoint = (pt) => (0, _embedpdf_core_geometry.applyPoint)(pdfToContent, pt);
52
48
  conv = {
53
49
  pageIndex: layout.index,
54
- toRect: (b) => toContent(pdfToContent, b)
50
+ toQuad: (q) => ({
51
+ upperStart: toPoint(q.p1),
52
+ upperEnd: toPoint(q.p2),
53
+ lowerStart: toPoint(q.p3),
54
+ lowerEnd: toPoint(q.p4)
55
+ })
55
56
  };
56
57
  }
57
58
  converters.set(pon, conv);
@@ -61,12 +62,21 @@ function createSearchCapability(ctx, config = {}) {
61
62
  for (const match of slice.matches) {
62
63
  const conv = converterFor(match.pageObjectNumber);
63
64
  if (!conv) continue;
65
+ const segments = match.segments.map((segment) => {
66
+ const quad = conv.toQuad(segment.quad);
67
+ return {
68
+ quad,
69
+ rect: (0, _embedpdf_core_geometry.textQuadBounds)(quad),
70
+ advance: segment.advance
71
+ };
72
+ });
64
73
  hits.push({
65
74
  pon: match.pageObjectNumber,
66
75
  pageIndex: conv.pageIndex,
67
76
  charStart: match.charStart,
68
77
  charCount: match.charCount,
69
- rects: match.rects.map(conv.toRect),
78
+ segments,
79
+ ...segments.length ? { bounds: (0, _embedpdf_core_geometry.boundsOfRects)(segments.map((s) => s.rect)) ?? void 0 } : {},
70
80
  ...match.snippet ? { snippet: match.snippet } : {}
71
81
  });
72
82
  }
@@ -132,7 +142,7 @@ function createSearchCapability(ctx, config = {}) {
132
142
  return;
133
143
  }
134
144
  if (startPage === void 0) {
135
- const stage = ctx.tryGet(_embedpdf_plugin_stage.StageToken);
145
+ const stage = ctx.tryGet(_embedpdf_plugin_stage_contract.StageToken);
136
146
  if (stage) startPage = ctx.document()?.pages[stage.currentPage()]?.pageObjectNumber;
137
147
  }
138
148
  try {
@@ -174,8 +184,8 @@ function createSearchCapability(ctx, config = {}) {
174
184
  ...config.reveal,
175
185
  ...reveal
176
186
  };
177
- ctx.tryGet(_embedpdf_plugin_stage.StageToken)?.reveal(hit.pageIndex, {
178
- rect: (0, _embedpdf_core_geometry.boundsOfRects)(hit.rects) ?? void 0,
187
+ ctx.tryGet(_embedpdf_plugin_stage_contract.StageToken)?.reveal(hit.pageIndex, {
188
+ rect: hit.bounds,
179
189
  anchor: arrival.anchor,
180
190
  behavior: arrival.behavior
181
191
  });
@@ -188,6 +198,11 @@ function createSearchCapability(ctx, config = {}) {
188
198
  ctx.dispatch({ type: "CLEAR" });
189
199
  };
190
200
  return {
201
+ canSearch: (mode) => {
202
+ const sec = ctx.doc?.security;
203
+ if (!sec) return false;
204
+ return sec.allows("doc.text.search") && (mode !== "full" || sec.allows("doc.text.copy"));
205
+ },
191
206
  search: (query, exec) => query.text.length === 0 ? clear() : void runSession(query, exec?.startPage),
192
207
  rerun: () => {
193
208
  const { query, status } = ctx.getState();
@@ -240,9 +255,6 @@ function createSearchCapability(ctx, config = {}) {
240
255
  };
241
256
  }
242
257
  //#endregion
243
- //#region src/types.ts
244
- const SearchToken = (0, _embedpdf_core.createCapabilityToken)("search");
245
- //#endregion
246
258
  //#region src/effects.ts
247
259
  const RERUN_DELAY_MS = 250;
248
260
  function registerSearchEffects(ctx) {
@@ -254,7 +266,7 @@ function registerSearchEffects(ctx) {
254
266
  if (timer !== null) clearTimeout(timer);
255
267
  timer = setTimeout(() => {
256
268
  timer = null;
257
- ctx.get(SearchToken).rerun();
269
+ ctx.get(require_contract.SearchToken).rerun();
258
270
  }, RERUN_DELAY_MS);
259
271
  });
260
272
  ctx.cleanup(() => {
@@ -336,16 +348,16 @@ const searchReducer = (state, action) => {
336
348
  */
337
349
  const searchPlugin = (config) => (0, _embedpdf_core.definePlugin)({
338
350
  id: "search",
339
- token: SearchToken,
351
+ token: require_contract.SearchToken,
340
352
  scope: "document",
341
- optional: [_embedpdf_plugin_stage.StageToken],
353
+ optional: [_embedpdf_plugin_stage_contract.StageToken],
342
354
  initialState: initialSearchState,
343
355
  reduce: searchReducer,
344
356
  capability: (ctx) => createSearchCapability(ctx, config),
345
357
  effects: registerSearchEffects
346
358
  });
347
359
  //#endregion
348
- exports.SearchToken = SearchToken;
360
+ exports.SearchToken = require_contract.SearchToken;
349
361
  exports.searchPlugin = searchPlugin;
350
362
  Object.defineProperty(exports, "validateSearchQuery", {
351
363
  enumerable: true,
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","names":["EngineError","EngineErrorCode","StageToken","StageToken"],"sources":["../src/capability.ts","../src/types.ts","../src/effects.ts","../src/reducer.ts","../src/search.plugin.ts"],"sourcesContent":["import type { PageObjectNumber, PluginContext } from '@embedpdf/core';\nimport {\n applyRect,\n boundsOfRects,\n pageGeometry,\n type Rect,\n type RectIn,\n} from '@embedpdf/core-geometry';\nimport { StageToken } from '@embedpdf/plugin-stage';\nimport { EngineError, EngineErrorCode } from '@embedpdf/engine-core/runtime';\nimport type {\n PdfRect,\n SearchMatch,\n SearchMode,\n SearchQuery,\n SearchSlice,\n} from '@embedpdf/engine-core/runtime';\n\nimport type {\n SearchAction,\n SearchCapability,\n SearchHit,\n SearchPluginConfig,\n SearchRevealOptions,\n SearchState,\n} from './types';\n\nconst EMPTY: SearchHit[] = [];\n\n/** The browser find-bar feel: hit at the top-middle, no zoom change. */\nconst DEFAULT_REVEAL: SearchRevealOptions = { anchor: { y: 0.35 }, behavior: 'smooth' };\n\n/** Local engines reject a denied scope before the worker; cloud maps 403. */\nconst isPermissionDenied = (err: unknown): boolean =>\n (err instanceof Error && err.name === 'PermissionDenied') ||\n EngineError.is(err, EngineErrorCode.Forbidden);\n\n/**\n * The search capability: a client-driven cursor loop over the engine's\n * budgeted slices. One generation counter arbitrates: a new `search()` (or\n * `clear()`, or a document mutation via `rerun()`) supersedes the running\n * loop, which aborts its in-flight slice and stops dispatching.\n *\n * Rect conversion happens HERE, once per slice: the engine's PDF-space\n * line rects → content space through the same page geometry the selection\n * plugin uses, so search highlights and selection highlights are pixel-twins.\n *\n * Stage integration is optional and additive: with a Stage present the scan\n * starts at the current page (viewport-first — the hits the user is looking\n * at arrive in the first slice) and `next()`/`prev()`/`goTo()` reveal the\n * hit's page. Without one, navigation still works; the host scrolls.\n */\nexport function createSearchCapability(\n ctx: PluginContext<SearchState, SearchAction>,\n config: SearchPluginConfig = {},\n): SearchCapability {\n let generation = 0;\n let inflight: { abort(reason?: unknown): void } | null = null;\n\n const toContent = (m: ReturnType<typeof pageGeometry>['pdfToContent'], b: PdfRect): Rect =>\n applyRect(\n m as never,\n {\n x: b.left,\n y: b.bottom,\n width: b.right - b.left,\n height: b.top - b.bottom,\n } as RectIn<'pdf'>,\n ) as Rect;\n\n interface PageConverter {\n pageIndex: number;\n toRect: (b: PdfRect) => Rect;\n }\n\n function hitsFromSlice(slice: SearchSlice): SearchHit[] {\n // zoom = 1: content space is scale-free; the layer applies the page\n // transform at render time (the selection idiom).\n const converters = new Map<PageObjectNumber, PageConverter | null>();\n const converterFor = (pon: PageObjectNumber): PageConverter | null => {\n let conv = converters.get(pon);\n if (conv !== undefined) return conv;\n const layout = ctx.document()?.pages.find((p) => p.pageObjectNumber === pon);\n if (!layout) {\n conv = null; // page vanished mid-search (delete); drop its hits\n } else {\n const { pdfToContent } = pageGeometry(\n { crop: layout.boxes.crop, rotation: layout.rotation, userUnit: layout.userUnit },\n 1,\n );\n conv = { pageIndex: layout.index, toRect: (b) => toContent(pdfToContent, b) };\n }\n converters.set(pon, conv);\n return conv;\n };\n\n const hits: SearchHit[] = [];\n for (const match of slice.matches) {\n const conv = converterFor(match.pageObjectNumber);\n if (!conv) continue;\n hits.push({\n pon: match.pageObjectNumber,\n pageIndex: conv.pageIndex,\n charStart: match.charStart,\n charCount: match.charCount,\n rects: match.rects.map(conv.toRect),\n ...(match.snippet ? { snippet: match.snippet } : {}),\n });\n }\n return hits;\n }\n\n /** How a collect() consumer observes the loop — the session dispatches\n * into state, findAll accumulates locally. */\n interface CollectSink {\n /** Called before the first slice AND again on a stale-cursor restart —\n * reset any accumulation. */\n onStart: () => void;\n onSlice: (hits: SearchHit[], scanned: number, total: number) => void;\n /** Superseded / aborted — the loop stops without completing. */\n isCancelled: () => boolean;\n setInflight: (pending: { abort(reason?: unknown): void } | null) => void;\n }\n\n /**\n * THE MECHANISM — one cursor loop over the engine's budgeted slices,\n * shared by the session (`search`) and the session-free service\n * (`findAll`): permission fallback ('full' → 'rects' when snippets are\n * denied, unless the caller pinned a mode), restart-once on a stale\n * cursor, rect conversion per slice. Resolves true on exhaustion, false\n * when cancelled; throws on error.\n */\n async function collect(\n query: SearchQuery,\n opts: { startPage?: PageObjectNumber; mode?: SearchMode },\n sink: CollectSink,\n ): Promise<boolean> {\n const doc = ctx.doc;\n if (!doc) return false;\n\n sink.onStart();\n\n let mode: SearchMode = opts.mode ?? 'full';\n const modePinned = opts.mode !== undefined;\n let cursor: string | undefined;\n let restarted = false;\n for (;;) {\n let slice: SearchSlice;\n try {\n const request = {\n query,\n mode,\n ...(cursor !== undefined\n ? { cursor }\n : opts.startPage !== undefined\n ? { startPage: opts.startPage }\n : {}),\n };\n const pending = doc.search.query(request);\n sink.setInflight(pending);\n slice = await pending;\n } catch (err) {\n if (sink.isCancelled()) return false; // superseded — the abort was ours\n // Snippets denied (no doc.text.copy)? Degrade to rect-only matches\n // instead of failing the whole search — find still works.\n if (!modePinned && mode === 'full' && cursor === undefined && isPermissionDenied(err)) {\n mode = 'rects';\n continue;\n }\n // A stale cursor rejects with InvalidArg — the document changed\n // under the loop, or the engine's session was recycled. Position\n // is lost, the query isn't: restart once from scratch.\n if (cursor !== undefined && !restarted && EngineError.is(err, EngineErrorCode.InvalidArg)) {\n restarted = true;\n cursor = undefined;\n sink.onStart();\n continue;\n }\n throw err;\n }\n if (sink.isCancelled()) return false;\n sink.onSlice(hitsFromSlice(slice), slice.scannedPages, slice.totalPages);\n if (slice.nextCursor === null) {\n sink.setInflight(null);\n return true;\n }\n cursor = slice.nextCursor;\n }\n }\n\n /** THE SESSION — collect() with state as the sink, generation-guarded. */\n async function runSession(query: SearchQuery, startPage?: PageObjectNumber): Promise<void> {\n const gen = ++generation;\n inflight?.abort('superseded');\n inflight = null;\n\n if (!ctx.doc) {\n ctx.dispatch({ type: 'CLEAR' });\n return;\n }\n\n // Viewport-first: begin scanning where the user is looking.\n if (startPage === undefined) {\n const stage = ctx.tryGet(StageToken);\n if (stage) startPage = ctx.document()?.pages[stage.currentPage()]?.pageObjectNumber;\n }\n\n try {\n const complete = await collect(\n query,\n { startPage },\n {\n onStart: () => ctx.dispatch({ type: 'START', query }),\n onSlice: (hits, scanned, total) => ctx.dispatch({ type: 'APPEND', hits, scanned, total }),\n isCancelled: () => gen !== generation,\n setInflight: (pending) => {\n if (gen === generation) inflight = pending;\n },\n },\n );\n if (complete && gen === generation) ctx.dispatch({ type: 'COMPLETE' });\n } catch (err) {\n if (gen !== generation) return;\n ctx.dispatch({ type: 'ERROR', message: err instanceof Error ? err.message : String(err) });\n }\n }\n\n function goTo(index: number, reveal?: SearchRevealOptions): SearchHit | null {\n const { hits } = ctx.getState();\n if (hits.length === 0) return null;\n const wrapped = ((index % hits.length) + hits.length) % hits.length;\n ctx.dispatch({ type: 'SET_ACTIVE', index: wrapped });\n const hit = hits[wrapped];\n // Positioned reveal: the HIT (not just its page) arrives at the anchor —\n // per-call override > plugin config > find-bar default. Zoom never changes.\n const arrival = { ...DEFAULT_REVEAL, ...config.reveal, ...reveal };\n ctx.tryGet(StageToken)?.reveal(hit.pageIndex, {\n rect: boundsOfRects(hit.rects) ?? undefined,\n anchor: arrival.anchor,\n behavior: arrival.behavior,\n });\n return hit;\n }\n\n const clear = () => {\n generation++;\n inflight?.abort('cleared');\n inflight = null;\n ctx.dispatch({ type: 'CLEAR' });\n };\n\n return {\n // An empty text is \"stop searching\", not a query — identical to clear().\n search: (query, exec) =>\n query.text.length === 0 ? clear() : void runSession(query, exec?.startPage),\n\n // Replay the stored query verbatim; the rescan starts viewport-first.\n rerun: () => {\n const { query, status } = ctx.getState();\n if (query && status !== 'idle') void runSession(query);\n },\n\n clear,\n\n next: () => goTo(ctx.getState().activeIndex + 1),\n prev: () => goTo(ctx.getState().activeIndex - 1),\n goTo,\n\n query: () => ctx.getState().query,\n\n findAll: async (query, opts = {}) => {\n if (!ctx.doc) return [];\n const all: SearchHit[] = [];\n const signal = opts.signal;\n // Independent lifecycle: its own abort handle, never the session's\n // generation counter — a findAll can neither supersede the user's\n // visible search nor be superseded by it.\n let pending: { abort(reason?: unknown): void } | null = null;\n const onAbort = () => pending?.abort(signal?.reason);\n signal?.addEventListener('abort', onAbort, { once: true });\n try {\n await collect(\n query,\n { mode: opts.mode },\n {\n onStart: () => {\n all.length = 0;\n },\n onSlice: (hits) => all.push(...hits),\n isCancelled: () => signal?.aborted ?? false,\n setInflight: (p) => {\n pending = p;\n },\n },\n );\n if (signal?.aborted) throw signal.reason ?? new Error('findAll aborted');\n return all;\n } finally {\n signal?.removeEventListener('abort', onAbort);\n }\n },\n\n status: () => ctx.getState().status,\n hits: () => ctx.getState().hits,\n hitCount: () => ctx.getState().hits.length,\n activeIndex: () => ctx.getState().activeIndex,\n activeHit: () => {\n const { hits, activeIndex } = ctx.getState();\n return activeIndex >= 0 && activeIndex < hits.length ? hits[activeIndex] : null;\n },\n hitsForPage: (pon) => {\n const { hits, hitsByPage } = ctx.getState();\n const indices = hitsByPage[pon];\n return indices?.length ? indices.map((i) => hits[i]) : EMPTY;\n },\n progress: () => ctx.getState().progress,\n errorMessage: () => ctx.getState().error,\n };\n}\n","import { createCapabilityToken, type PageObjectNumber } from '@embedpdf/core';\nimport type { Rect } from '@embedpdf/core-geometry';\nimport type { RevealAnchor, ScrollBehaviorKind } from '@embedpdf/plugin-stage';\nimport type { SearchMode, SearchQuery, SearchSnippet } from '@embedpdf/engine-core/runtime';\n\n/**\n * One match, viewer-shaped: the engine's PDF-space line rects converted to\n * CONTENT space (y-down — the same space selection rects live in), plus the\n * page's display index so navigation can `reveal()` without a lookup.\n * `charStart`/`charCount` keep the engine's text-page offsets, so a hit can\n * later seed a selection or a highlight annotation without re-searching.\n */\nexport interface SearchHit {\n pon: PageObjectNumber;\n pageIndex: number;\n charStart: number;\n charCount: number;\n rects: Rect[];\n snippet?: SearchSnippet;\n}\n\n/**\n * `searching` means the slice loop is still walking pages — results are\n * usable the moment they appear (matches stream in viewport-first order).\n */\nexport type SearchStatus = 'idle' | 'searching' | 'complete' | 'error';\n\n/**\n * Execution options for the session `search()` — HOW the scan runs, never\n * part of what the query MEANS (which is why they're not on `SearchQuery`\n * and are never stored or restored into a search box).\n */\nexport interface SearchExecOptions {\n /** Scan origin override. Defaults to the Stage's current page (viewport-first). */\n startPage?: PageObjectNumber;\n}\n\n/** Options for the session-free `findAll()` service. */\nexport interface SearchFindAllOptions {\n /** Abort the scan; the returned promise rejects with `signal.reason`. */\n signal?: AbortSignal;\n /**\n * Pin the slice mode. Default: `'full'` with automatic `'rects'`\n * fallback when snippets are denied — pass `'rects'` when you only need\n * geometry (e.g. redact-by-term) to skip snippet extraction entirely.\n */\n mode?: SearchMode;\n}\n\nexport interface SearchState {\n /**\n * The query in flight/completed, null when idle/cleared — THE stored\n * form of the search intent, the same flat `SearchQuery` the engine\n * matches on, the wire caches by, and a search box renders. Survives\n * any search UI unmounting (results are document-scoped; so is this).\n */\n query: SearchQuery | null;\n status: SearchStatus;\n hits: SearchHit[];\n /** pon → indices into `hits` — the per-page layer's read model. */\n hitsByPage: Record<number, number[]>;\n /** Index into `hits`, -1 = none. */\n activeIndex: number;\n progress: { scanned: number; total: number };\n error: string | null;\n}\n\nexport type SearchAction =\n | { type: 'START'; query: SearchQuery }\n | { type: 'APPEND'; hits: SearchHit[]; scanned: number; total: number }\n | { type: 'COMPLETE' }\n | { type: 'ERROR'; message: string }\n | { type: 'SET_ACTIVE'; index: number }\n | { type: 'CLEAR' };\n\n/**\n * How hit navigation arrives (forwarded to the Stage's positioned reveal).\n * The default is the browser find-bar feel: the hit lands at the top-middle\n * of the viewport (`anchor: { y: 0.35 }`), horizontal only moves when the\n * hit is off-screen, zoom never changes, smooth tween.\n */\nexport interface SearchRevealOptions {\n anchor?: RevealAnchor;\n behavior?: ScrollBehaviorKind;\n}\n\nexport interface SearchPluginConfig {\n /** Arrival defaults for `next()`/`prev()`/`goTo()`. */\n reveal?: SearchRevealOptions;\n}\n\n/**\n * The search plugin is a find SERVICE plus one user-visible search SESSION\n * per document. `findAll` is the service; `search` is the session; the\n * sidebar, the highlights layer, and next/prev render the session.\n */\nexport interface SearchCapability {\n // ── the session: THE user-visible find, one per document ────────────────\n /**\n * Start a new search (supersedes and aborts any running one). Results\n * stream into state slice by slice; the first hit becomes active but the\n * camera does NOT move until `next()`/`prev()`/`goTo()`. An empty\n * `query.text` is \"stop searching\" — identical to `clear()`.\n */\n search(query: SearchQuery, exec?: SearchExecOptions): void;\n /** Re-run the current query from scratch (used after document mutations). */\n rerun(): void;\n clear(): void;\n\n /** Advance to the next/previous hit (wraps) and reveal it (see {@link SearchRevealOptions}). */\n next(): SearchHit | null;\n prev(): SearchHit | null;\n /** Jump to a specific hit index and reveal it; `reveal` overrides the plugin defaults. */\n goTo(index: number, reveal?: SearchRevealOptions): SearchHit | null;\n\n /** The session's query — what a search box renders. Null when idle. */\n query(): SearchQuery | null;\n status(): SearchStatus;\n hits(): SearchHit[];\n hitCount(): number;\n activeIndex(): number;\n activeHit(): SearchHit | null;\n /** Hits on one page — the layer's input. Element identity is stable. */\n hitsForPage(pon: PageObjectNumber): SearchHit[];\n progress(): { scanned: number; total: number };\n errorMessage(): string | null;\n\n // ── the mechanism: session-free find service ─────────────────────────────\n /**\n * Run a query to completion and return every hit. Touches NO state — no\n * sidebar, no highlights, no camera, no activeIndex; the user's visible\n * search is never superseded. The primitive other features build on\n * (redact-by-term, link detection, occurrence badges). Scans in natural\n * page order; concurrent calls are independent.\n */\n findAll(query: SearchQuery, opts?: SearchFindAllOptions): Promise<SearchHit[]>;\n}\n\nexport const SearchToken = createCapabilityToken<SearchCapability>('search');\n","/**\n * Keeps results honest across document mutations. Every confirmed\n * mutation (own or remote) bumps the engine's mutation sequence, which\n * invalidates search cursors AND can change what's findable (redaction,\n * page delete, import). The correct reaction is always the same: re-run\n * the current query from scratch. Reruns are coalesced — a burst of\n * events (multi-part mutation, SSE catch-up) triggers one rescan.\n */\nimport type { EffectContext } from '@embedpdf/core';\n\nimport { SearchToken } from './types';\nimport type { SearchAction, SearchState } from './types';\n\nconst RERUN_DELAY_MS = 250;\n\nexport function registerSearchEffects(ctx: EffectContext<SearchState, SearchAction>): void {\n const doc = ctx.doc;\n if (!doc) return;\n\n let timer: ReturnType<typeof setTimeout> | null = null;\n const unsubscribe = doc.events.subscribe(() => {\n if (ctx.getState().status === 'idle') return;\n if (timer !== null) clearTimeout(timer);\n timer = setTimeout(() => {\n timer = null;\n ctx.get(SearchToken).rerun();\n }, RERUN_DELAY_MS);\n });\n\n ctx.cleanup(() => {\n if (timer !== null) clearTimeout(timer);\n unsubscribe();\n });\n}\n","import type { SearchAction, SearchState } from './types';\n\nexport const initialSearchState = (): SearchState => ({\n query: null,\n status: 'idle',\n hits: [],\n hitsByPage: {},\n activeIndex: -1,\n progress: { scanned: 0, total: 0 },\n error: null,\n});\n\nexport const searchReducer = (state: SearchState, action: SearchAction): SearchState => {\n switch (action.type) {\n case 'START':\n return { ...initialSearchState(), query: action.query, status: 'searching' };\n case 'APPEND': {\n if (action.hits.length === 0) {\n return { ...state, progress: { scanned: action.scanned, total: action.total } };\n }\n const hits = state.hits.concat(action.hits);\n const hitsByPage = { ...state.hitsByPage };\n for (let i = state.hits.length; i < hits.length; i++) {\n const pon = hits[i].pon;\n hitsByPage[pon] = (hitsByPage[pon] ?? []).concat(i);\n }\n return {\n ...state,\n hits,\n hitsByPage,\n // The first hit becomes active (so \"1/N\" reads right) — navigation,\n // not the stream, moves the camera.\n activeIndex: state.activeIndex === -1 ? 0 : state.activeIndex,\n progress: { scanned: action.scanned, total: action.total },\n };\n }\n case 'COMPLETE':\n return { ...state, status: 'complete' };\n case 'ERROR':\n return { ...state, status: 'error', error: action.message };\n case 'SET_ACTIVE':\n return { ...state, activeIndex: action.index };\n case 'CLEAR':\n return initialSearchState();\n default:\n return state;\n }\n};\n","import { definePlugin } from '@embedpdf/core';\nimport { StageToken } from '@embedpdf/plugin-stage';\n\nimport { createSearchCapability } from './capability';\nimport { registerSearchEffects } from './effects';\nimport { initialSearchState, searchReducer } from './reducer';\nimport { SearchToken } from './types';\nimport type { SearchAction, SearchCapability, SearchPluginConfig, SearchState } from './types';\n\n/**\n * Document text search over `doc.search` — the engine's budgeted,\n * cursor-resumable slices. Document-scoped; no pointer handling, so it\n * needs no interaction hub. The Stage is optional: when present, scans\n * start at the current page (viewport-first) and hit navigation reveals\n * the hit's page; without it the host owns scrolling.\n */\nexport const searchPlugin = (config?: SearchPluginConfig) =>\n definePlugin<SearchState, SearchAction, SearchCapability>({\n id: 'search',\n token: SearchToken,\n scope: 'document',\n optional: [StageToken],\n initialState: initialSearchState,\n reduce: searchReducer,\n capability: (ctx) => createSearchCapability(ctx, config),\n effects: registerSearchEffects,\n });\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.cjs","names":["EngineError","EngineErrorCode","StageToken","SearchToken","SearchToken","StageToken"],"sources":["../src/capability.ts","../src/effects.ts","../src/reducer.ts","../src/search.plugin.ts"],"sourcesContent":["import type { PageObjectNumber, PluginContext } from '@embedpdf/core';\nimport { type PointIn, type TextQuad, applyPoint, boundsOfRects, pageGeometry, textQuadBounds } from '@embedpdf/core-geometry';\nimport { StageToken } from '@embedpdf/plugin-stage/contract';\nimport { EngineError, EngineErrorCode } from '@embedpdf/engine-core/runtime';\nimport type {\n PdfQuad,\n PdfRect,\n SearchMatch,\n SearchMode,\n SearchQuery,\n SearchSlice,\n} from '@embedpdf/engine-core/runtime';\n\nimport type {\n SearchAction,\n SearchCapability,\n SearchHit,\n SearchPluginConfig,\n SearchRevealOptions,\n SearchState,\n} from './types';\n\nconst EMPTY: SearchHit[] = [];\n\n/** The browser find-bar feel: hit at the top-middle, no zoom change. */\nconst DEFAULT_REVEAL: SearchRevealOptions = { anchor: { y: 0.35 }, behavior: 'smooth' };\n\n/** Local engines reject a denied scope before the worker; cloud maps 403. */\nconst isPermissionDenied = (err: unknown): boolean =>\n (err instanceof Error && err.name === 'PermissionDenied') ||\n EngineError.is(err, EngineErrorCode.Forbidden);\n\n/**\n * The search capability: a client-driven cursor loop over the engine's\n * budgeted slices. One generation counter arbitrates: a new `search()` (or\n * `clear()`, or a document mutation via `rerun()`) supersedes the running\n * loop, which aborts its in-flight slice and stops dispatching.\n *\n * Rect conversion happens HERE, once per slice: the engine's PDF-space\n * line rects → content space through the same page geometry the selection\n * plugin uses, so search highlights and selection highlights are pixel-twins.\n *\n * Stage integration is optional and additive: with a Stage present the scan\n * starts at the current page (viewport-first — the hits the user is looking\n * at arrive in the first slice) and `next()`/`prev()`/`goTo()` reveal the\n * hit's page. Without one, navigation still works; the host scrolls.\n */\nexport function createSearchCapability(\n ctx: PluginContext<SearchState, SearchAction>,\n config: SearchPluginConfig = {},\n): SearchCapability {\n let generation = 0;\n let inflight: { abort(reason?: unknown): void } | null = null;\n\n interface PageConverter {\n pageIndex: number;\n toQuad: (q: PdfQuad) => TextQuad;\n }\n\n function hitsFromSlice(slice: SearchSlice): SearchHit[] {\n // zoom = 1: content space is scale-free; the layer applies the page\n // transform at render time (the selection idiom).\n const converters = new Map<PageObjectNumber, PageConverter | null>();\n const converterFor = (pon: PageObjectNumber): PageConverter | null => {\n let conv = converters.get(pon);\n if (conv !== undefined) return conv;\n const layout = ctx.document()?.pages.find((p) => p.pageObjectNumber === pon);\n if (!layout) {\n conv = null; // page vanished mid-search (delete); drop its hits\n } else {\n const { pdfToContent } = pageGeometry(\n { crop: layout.boxes.crop, rotation: layout.rotation, userUnit: layout.userUnit },\n 1,\n );\n // Engine quads carry frame-geometric slot semantics (p1..p4 =\n // upper-start, upper-end, lower-start, lower-end) — a trusted\n // producer, so the y-flip maps corners straight onto their names.\n const toPoint = (pt: { x: number; y: number }) =>\n applyPoint(pdfToContent as never, pt as PointIn<'pdf'>) as { x: number; y: number };\n conv = {\n pageIndex: layout.index,\n toQuad: (q) => ({\n upperStart: toPoint(q.p1),\n upperEnd: toPoint(q.p2),\n lowerStart: toPoint(q.p3),\n lowerEnd: toPoint(q.p4),\n }),\n };\n }\n converters.set(pon, conv);\n return conv;\n };\n\n const hits: SearchHit[] = [];\n for (const match of slice.matches) {\n const conv = converterFor(match.pageObjectNumber);\n if (!conv) continue;\n const segments = match.segments.map((segment) => {\n const quad = conv.toQuad(segment.quad);\n // `rect` is recomputed from the MAPPED quad so the pair can't drift.\n return { quad, rect: textQuadBounds(quad), advance: segment.advance };\n });\n hits.push({\n pon: match.pageObjectNumber,\n pageIndex: conv.pageIndex,\n charStart: match.charStart,\n charCount: match.charCount,\n segments,\n ...(segments.length\n ? { bounds: boundsOfRects(segments.map((s) => s.rect)) ?? undefined }\n : {}),\n ...(match.snippet ? { snippet: match.snippet } : {}),\n });\n }\n return hits;\n }\n\n /** How a collect() consumer observes the loop — the session dispatches\n * into state, findAll accumulates locally. */\n interface CollectSink {\n /** Called before the first slice AND again on a stale-cursor restart —\n * reset any accumulation. */\n onStart: () => void;\n onSlice: (hits: SearchHit[], scanned: number, total: number) => void;\n /** Superseded / aborted — the loop stops without completing. */\n isCancelled: () => boolean;\n setInflight: (pending: { abort(reason?: unknown): void } | null) => void;\n }\n\n /**\n * THE MECHANISM — one cursor loop over the engine's budgeted slices,\n * shared by the session (`search`) and the session-free service\n * (`findAll`): permission fallback ('full' → 'rects' when snippets are\n * denied, unless the caller pinned a mode), restart-once on a stale\n * cursor, rect conversion per slice. Resolves true on exhaustion, false\n * when cancelled; throws on error.\n */\n async function collect(\n query: SearchQuery,\n opts: { startPage?: PageObjectNumber; mode?: SearchMode },\n sink: CollectSink,\n ): Promise<boolean> {\n const doc = ctx.doc;\n if (!doc) return false;\n\n sink.onStart();\n\n let mode: SearchMode = opts.mode ?? 'full';\n const modePinned = opts.mode !== undefined;\n let cursor: string | undefined;\n let restarted = false;\n for (;;) {\n let slice: SearchSlice;\n try {\n const request = {\n query,\n mode,\n ...(cursor !== undefined\n ? { cursor }\n : opts.startPage !== undefined\n ? { startPage: opts.startPage }\n : {}),\n };\n const pending = doc.search.query(request);\n sink.setInflight(pending);\n slice = await pending;\n } catch (err) {\n if (sink.isCancelled()) return false; // superseded — the abort was ours\n // Snippets denied (no doc.text.copy)? Degrade to rect-only matches\n // instead of failing the whole search — find still works.\n if (!modePinned && mode === 'full' && cursor === undefined && isPermissionDenied(err)) {\n mode = 'rects';\n continue;\n }\n // A stale cursor rejects with InvalidArg — the document changed\n // under the loop, or the engine's session was recycled. Position\n // is lost, the query isn't: restart once from scratch.\n if (cursor !== undefined && !restarted && EngineError.is(err, EngineErrorCode.InvalidArg)) {\n restarted = true;\n cursor = undefined;\n sink.onStart();\n continue;\n }\n throw err;\n }\n if (sink.isCancelled()) return false;\n sink.onSlice(hitsFromSlice(slice), slice.scannedPages, slice.totalPages);\n if (slice.nextCursor === null) {\n sink.setInflight(null);\n return true;\n }\n cursor = slice.nextCursor;\n }\n }\n\n /** THE SESSION — collect() with state as the sink, generation-guarded. */\n async function runSession(query: SearchQuery, startPage?: PageObjectNumber): Promise<void> {\n const gen = ++generation;\n inflight?.abort('superseded');\n inflight = null;\n\n if (!ctx.doc) {\n ctx.dispatch({ type: 'CLEAR' });\n return;\n }\n\n // Viewport-first: begin scanning where the user is looking.\n if (startPage === undefined) {\n const stage = ctx.tryGet(StageToken);\n if (stage) startPage = ctx.document()?.pages[stage.currentPage()]?.pageObjectNumber;\n }\n\n try {\n const complete = await collect(\n query,\n { startPage },\n {\n onStart: () => ctx.dispatch({ type: 'START', query }),\n onSlice: (hits, scanned, total) => ctx.dispatch({ type: 'APPEND', hits, scanned, total }),\n isCancelled: () => gen !== generation,\n setInflight: (pending) => {\n if (gen === generation) inflight = pending;\n },\n },\n );\n if (complete && gen === generation) ctx.dispatch({ type: 'COMPLETE' });\n } catch (err) {\n if (gen !== generation) return;\n ctx.dispatch({ type: 'ERROR', message: err instanceof Error ? err.message : String(err) });\n }\n }\n\n function goTo(index: number, reveal?: SearchRevealOptions): SearchHit | null {\n const { hits } = ctx.getState();\n if (hits.length === 0) return null;\n const wrapped = ((index % hits.length) + hits.length) % hits.length;\n ctx.dispatch({ type: 'SET_ACTIVE', index: wrapped });\n const hit = hits[wrapped];\n // Positioned reveal: the HIT (not just its page) arrives at the anchor —\n // per-call override > plugin config > find-bar default. Zoom never changes.\n const arrival = { ...DEFAULT_REVEAL, ...config.reveal, ...reveal };\n ctx.tryGet(StageToken)?.reveal(hit.pageIndex, {\n rect: hit.bounds,\n anchor: arrival.anchor,\n behavior: arrival.behavior,\n });\n return hit;\n }\n\n const clear = () => {\n generation++;\n inflight?.abort('cleared');\n inflight = null;\n ctx.dispatch({ type: 'CLEAR' });\n };\n\n return {\n // The twin. Search is a pure request, so the VERBS carry no client gate\n // (the engine enforces; the ERROR state reports) — the twin exists for\n // affordance hiding. 'full' composes both capabilities because a snippet\n // reproduces document text; the session's own degradation means plain\n // `canSearch()` is exactly \"would `search()` produce hits\".\n canSearch: (mode) => {\n const sec = ctx.doc?.security;\n if (!sec) return false;\n return sec.allows('doc.text.search') && (mode !== 'full' || sec.allows('doc.text.copy'));\n },\n\n // An empty text is \"stop searching\", not a query — identical to clear().\n search: (query, exec) =>\n query.text.length === 0 ? clear() : void runSession(query, exec?.startPage),\n\n // Replay the stored query verbatim; the rescan starts viewport-first.\n rerun: () => {\n const { query, status } = ctx.getState();\n if (query && status !== 'idle') void runSession(query);\n },\n\n clear,\n\n next: () => goTo(ctx.getState().activeIndex + 1),\n prev: () => goTo(ctx.getState().activeIndex - 1),\n goTo,\n\n query: () => ctx.getState().query,\n\n findAll: async (query, opts = {}) => {\n if (!ctx.doc) return [];\n const all: SearchHit[] = [];\n const signal = opts.signal;\n // Independent lifecycle: its own abort handle, never the session's\n // generation counter — a findAll can neither supersede the user's\n // visible search nor be superseded by it.\n let pending: { abort(reason?: unknown): void } | null = null;\n const onAbort = () => pending?.abort(signal?.reason);\n signal?.addEventListener('abort', onAbort, { once: true });\n try {\n await collect(\n query,\n { mode: opts.mode },\n {\n onStart: () => {\n all.length = 0;\n },\n onSlice: (hits) => all.push(...hits),\n isCancelled: () => signal?.aborted ?? false,\n setInflight: (p) => {\n pending = p;\n },\n },\n );\n if (signal?.aborted) throw signal.reason ?? new Error('findAll aborted');\n return all;\n } finally {\n signal?.removeEventListener('abort', onAbort);\n }\n },\n\n status: () => ctx.getState().status,\n hits: () => ctx.getState().hits,\n hitCount: () => ctx.getState().hits.length,\n activeIndex: () => ctx.getState().activeIndex,\n activeHit: () => {\n const { hits, activeIndex } = ctx.getState();\n return activeIndex >= 0 && activeIndex < hits.length ? hits[activeIndex] : null;\n },\n hitsForPage: (pon) => {\n const { hits, hitsByPage } = ctx.getState();\n const indices = hitsByPage[pon];\n return indices?.length ? indices.map((i) => hits[i]) : EMPTY;\n },\n progress: () => ctx.getState().progress,\n errorMessage: () => ctx.getState().error,\n };\n}\n","/**\n * Keeps results honest across document mutations. Every confirmed\n * mutation (own or remote) bumps the engine's mutation sequence, which\n * invalidates search cursors AND can change what's findable (redaction,\n * page delete, import). The correct reaction is always the same: re-run\n * the current query from scratch. Reruns are coalesced — a burst of\n * events (multi-part mutation, SSE catch-up) triggers one rescan.\n */\nimport type { EffectContext } from '@embedpdf/core';\n\nimport { SearchToken } from './types';\nimport type { SearchAction, SearchState } from './types';\n\nconst RERUN_DELAY_MS = 250;\n\nexport function registerSearchEffects(ctx: EffectContext<SearchState, SearchAction>): void {\n const doc = ctx.doc;\n if (!doc) return;\n\n let timer: ReturnType<typeof setTimeout> | null = null;\n const unsubscribe = doc.events.subscribe(() => {\n if (ctx.getState().status === 'idle') return;\n if (timer !== null) clearTimeout(timer);\n timer = setTimeout(() => {\n timer = null;\n ctx.get(SearchToken).rerun();\n }, RERUN_DELAY_MS);\n });\n\n ctx.cleanup(() => {\n if (timer !== null) clearTimeout(timer);\n unsubscribe();\n });\n}\n","import type { SearchAction, SearchState } from './types';\n\nexport const initialSearchState = (): SearchState => ({\n query: null,\n status: 'idle',\n hits: [],\n hitsByPage: {},\n activeIndex: -1,\n progress: { scanned: 0, total: 0 },\n error: null,\n});\n\nexport const searchReducer = (state: SearchState, action: SearchAction): SearchState => {\n switch (action.type) {\n case 'START':\n return { ...initialSearchState(), query: action.query, status: 'searching' };\n case 'APPEND': {\n if (action.hits.length === 0) {\n return { ...state, progress: { scanned: action.scanned, total: action.total } };\n }\n const hits = state.hits.concat(action.hits);\n const hitsByPage = { ...state.hitsByPage };\n for (let i = state.hits.length; i < hits.length; i++) {\n const pon = hits[i].pon;\n hitsByPage[pon] = (hitsByPage[pon] ?? []).concat(i);\n }\n return {\n ...state,\n hits,\n hitsByPage,\n // The first hit becomes active (so \"1/N\" reads right) — navigation,\n // not the stream, moves the camera.\n activeIndex: state.activeIndex === -1 ? 0 : state.activeIndex,\n progress: { scanned: action.scanned, total: action.total },\n };\n }\n case 'COMPLETE':\n return { ...state, status: 'complete' };\n case 'ERROR':\n return { ...state, status: 'error', error: action.message };\n case 'SET_ACTIVE':\n return { ...state, activeIndex: action.index };\n case 'CLEAR':\n return initialSearchState();\n default:\n return state;\n }\n};\n","import { definePlugin } from '@embedpdf/core';\nimport { StageToken } from '@embedpdf/plugin-stage/contract';\n\nimport { createSearchCapability } from './capability';\nimport { registerSearchEffects } from './effects';\nimport { initialSearchState, searchReducer } from './reducer';\nimport { SearchToken } from './types';\nimport type { SearchAction, SearchCapability, SearchPluginConfig, SearchState } from './types';\n\n/**\n * Document text search over `doc.search` — the engine's budgeted,\n * cursor-resumable slices. Document-scoped; no pointer handling, so it\n * needs no interaction hub. The Stage is optional: when present, scans\n * start at the current page (viewport-first) and hit navigation reveals\n * the hit's page; without it the host owns scrolling.\n */\nexport const searchPlugin = (config?: SearchPluginConfig) =>\n definePlugin<SearchState, SearchAction, SearchCapability>({\n id: 'search',\n token: SearchToken,\n scope: 'document',\n optional: [StageToken],\n initialState: initialSearchState,\n reduce: searchReducer,\n capability: (ctx) => createSearchCapability(ctx, config),\n effects: registerSearchEffects,\n });\n"],"mappings":"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"}
package/dist/index.d.cts CHANGED
@@ -1,147 +1,4 @@
1
- import { PageObjectNumber } from "@embedpdf/core";
2
- import { Rect } from "@embedpdf/core-geometry";
3
- import { RevealAnchor, ScrollBehaviorKind } from "@embedpdf/plugin-stage";
4
- import { SearchMode, SearchQuery, SearchQuery as SearchQuery$1, SearchQueryIssue, SearchQueryValidation, SearchRegexValidation, SearchSnippet, SearchSnippet as SearchSnippet$1, validateSearchQuery, validateSearchRegex } from "@embedpdf/engine-core/runtime";
5
- //#region src/types.d.ts
6
- /**
7
- * One match, viewer-shaped: the engine's PDF-space line rects converted to
8
- * CONTENT space (y-down — the same space selection rects live in), plus the
9
- * page's display index so navigation can `reveal()` without a lookup.
10
- * `charStart`/`charCount` keep the engine's text-page offsets, so a hit can
11
- * later seed a selection or a highlight annotation without re-searching.
12
- */
13
- interface SearchHit {
14
- pon: PageObjectNumber;
15
- pageIndex: number;
16
- charStart: number;
17
- charCount: number;
18
- rects: Rect[];
19
- snippet?: SearchSnippet$1;
20
- }
21
- /**
22
- * `searching` means the slice loop is still walking pages — results are
23
- * usable the moment they appear (matches stream in viewport-first order).
24
- */
25
- type SearchStatus = 'idle' | 'searching' | 'complete' | 'error';
26
- /**
27
- * Execution options for the session `search()` — HOW the scan runs, never
28
- * part of what the query MEANS (which is why they're not on `SearchQuery`
29
- * and are never stored or restored into a search box).
30
- */
31
- interface SearchExecOptions {
32
- /** Scan origin override. Defaults to the Stage's current page (viewport-first). */
33
- startPage?: PageObjectNumber;
34
- }
35
- /** Options for the session-free `findAll()` service. */
36
- interface SearchFindAllOptions {
37
- /** Abort the scan; the returned promise rejects with `signal.reason`. */
38
- signal?: AbortSignal;
39
- /**
40
- * Pin the slice mode. Default: `'full'` with automatic `'rects'`
41
- * fallback when snippets are denied — pass `'rects'` when you only need
42
- * geometry (e.g. redact-by-term) to skip snippet extraction entirely.
43
- */
44
- mode?: SearchMode;
45
- }
46
- interface SearchState {
47
- /**
48
- * The query in flight/completed, null when idle/cleared — THE stored
49
- * form of the search intent, the same flat `SearchQuery` the engine
50
- * matches on, the wire caches by, and a search box renders. Survives
51
- * any search UI unmounting (results are document-scoped; so is this).
52
- */
53
- query: SearchQuery$1 | null;
54
- status: SearchStatus;
55
- hits: SearchHit[];
56
- /** pon → indices into `hits` — the per-page layer's read model. */
57
- hitsByPage: Record<number, number[]>;
58
- /** Index into `hits`, -1 = none. */
59
- activeIndex: number;
60
- progress: {
61
- scanned: number;
62
- total: number;
63
- };
64
- error: string | null;
65
- }
66
- type SearchAction = {
67
- type: 'START';
68
- query: SearchQuery$1;
69
- } | {
70
- type: 'APPEND';
71
- hits: SearchHit[];
72
- scanned: number;
73
- total: number;
74
- } | {
75
- type: 'COMPLETE';
76
- } | {
77
- type: 'ERROR';
78
- message: string;
79
- } | {
80
- type: 'SET_ACTIVE';
81
- index: number;
82
- } | {
83
- type: 'CLEAR';
84
- };
85
- /**
86
- * How hit navigation arrives (forwarded to the Stage's positioned reveal).
87
- * The default is the browser find-bar feel: the hit lands at the top-middle
88
- * of the viewport (`anchor: { y: 0.35 }`), horizontal only moves when the
89
- * hit is off-screen, zoom never changes, smooth tween.
90
- */
91
- interface SearchRevealOptions {
92
- anchor?: RevealAnchor;
93
- behavior?: ScrollBehaviorKind;
94
- }
95
- interface SearchPluginConfig {
96
- /** Arrival defaults for `next()`/`prev()`/`goTo()`. */
97
- reveal?: SearchRevealOptions;
98
- }
99
- /**
100
- * The search plugin is a find SERVICE plus one user-visible search SESSION
101
- * per document. `findAll` is the service; `search` is the session; the
102
- * sidebar, the highlights layer, and next/prev render the session.
103
- */
104
- interface SearchCapability {
105
- /**
106
- * Start a new search (supersedes and aborts any running one). Results
107
- * stream into state slice by slice; the first hit becomes active but the
108
- * camera does NOT move until `next()`/`prev()`/`goTo()`. An empty
109
- * `query.text` is "stop searching" — identical to `clear()`.
110
- */
111
- search(query: SearchQuery$1, exec?: SearchExecOptions): void;
112
- /** Re-run the current query from scratch (used after document mutations). */
113
- rerun(): void;
114
- clear(): void;
115
- /** Advance to the next/previous hit (wraps) and reveal it (see {@link SearchRevealOptions}). */
116
- next(): SearchHit | null;
117
- prev(): SearchHit | null;
118
- /** Jump to a specific hit index and reveal it; `reveal` overrides the plugin defaults. */
119
- goTo(index: number, reveal?: SearchRevealOptions): SearchHit | null;
120
- /** The session's query — what a search box renders. Null when idle. */
121
- query(): SearchQuery$1 | null;
122
- status(): SearchStatus;
123
- hits(): SearchHit[];
124
- hitCount(): number;
125
- activeIndex(): number;
126
- activeHit(): SearchHit | null;
127
- /** Hits on one page — the layer's input. Element identity is stable. */
128
- hitsForPage(pon: PageObjectNumber): SearchHit[];
129
- progress(): {
130
- scanned: number;
131
- total: number;
132
- };
133
- errorMessage(): string | null;
134
- /**
135
- * Run a query to completion and return every hit. Touches NO state — no
136
- * sidebar, no highlights, no camera, no activeIndex; the user's visible
137
- * search is never superseded. The primitive other features build on
138
- * (redact-by-term, link detection, occurrence badges). Scans in natural
139
- * page order; concurrent calls are independent.
140
- */
141
- findAll(query: SearchQuery$1, opts?: SearchFindAllOptions): Promise<SearchHit[]>;
142
- }
143
- declare const SearchToken: import("@embedpdf/core").CapabilityToken<SearchCapability>;
144
- //#endregion
1
+ import { _ as SearchToken, a as SearchSnippet, c as SearchAction, d as SearchFindAllOptions, f as SearchHit, g as SearchStatus, h as SearchState, i as SearchRegexValidation, l as SearchCapability, m as SearchRevealOptions, n as SearchQueryIssue, o as validateSearchQuery, p as SearchPluginConfig, r as SearchQueryValidation, s as validateSearchRegex, t as SearchQuery, u as SearchExecOptions } from "./contract-CSb66POC.cjs";
145
2
  //#region src/search.plugin.d.ts
146
3
  /**
147
4
  * Document text search over `doc.search` — the engine's budgeted,
package/dist/index.d.ts CHANGED
@@ -1,147 +1,4 @@
1
- import { PageObjectNumber } from "@embedpdf/core";
2
- import { RevealAnchor, ScrollBehaviorKind } from "@embedpdf/plugin-stage";
3
- import { Rect } from "@embedpdf/core-geometry";
4
- import { SearchMode, SearchQuery, SearchQuery as SearchQuery$1, SearchQueryIssue, SearchQueryValidation, SearchRegexValidation, SearchSnippet, SearchSnippet as SearchSnippet$1, validateSearchQuery, validateSearchRegex } from "@embedpdf/engine-core/runtime";
5
- //#region src/types.d.ts
6
- /**
7
- * One match, viewer-shaped: the engine's PDF-space line rects converted to
8
- * CONTENT space (y-down — the same space selection rects live in), plus the
9
- * page's display index so navigation can `reveal()` without a lookup.
10
- * `charStart`/`charCount` keep the engine's text-page offsets, so a hit can
11
- * later seed a selection or a highlight annotation without re-searching.
12
- */
13
- interface SearchHit {
14
- pon: PageObjectNumber;
15
- pageIndex: number;
16
- charStart: number;
17
- charCount: number;
18
- rects: Rect[];
19
- snippet?: SearchSnippet$1;
20
- }
21
- /**
22
- * `searching` means the slice loop is still walking pages — results are
23
- * usable the moment they appear (matches stream in viewport-first order).
24
- */
25
- type SearchStatus = 'idle' | 'searching' | 'complete' | 'error';
26
- /**
27
- * Execution options for the session `search()` — HOW the scan runs, never
28
- * part of what the query MEANS (which is why they're not on `SearchQuery`
29
- * and are never stored or restored into a search box).
30
- */
31
- interface SearchExecOptions {
32
- /** Scan origin override. Defaults to the Stage's current page (viewport-first). */
33
- startPage?: PageObjectNumber;
34
- }
35
- /** Options for the session-free `findAll()` service. */
36
- interface SearchFindAllOptions {
37
- /** Abort the scan; the returned promise rejects with `signal.reason`. */
38
- signal?: AbortSignal;
39
- /**
40
- * Pin the slice mode. Default: `'full'` with automatic `'rects'`
41
- * fallback when snippets are denied — pass `'rects'` when you only need
42
- * geometry (e.g. redact-by-term) to skip snippet extraction entirely.
43
- */
44
- mode?: SearchMode;
45
- }
46
- interface SearchState {
47
- /**
48
- * The query in flight/completed, null when idle/cleared — THE stored
49
- * form of the search intent, the same flat `SearchQuery` the engine
50
- * matches on, the wire caches by, and a search box renders. Survives
51
- * any search UI unmounting (results are document-scoped; so is this).
52
- */
53
- query: SearchQuery$1 | null;
54
- status: SearchStatus;
55
- hits: SearchHit[];
56
- /** pon → indices into `hits` — the per-page layer's read model. */
57
- hitsByPage: Record<number, number[]>;
58
- /** Index into `hits`, -1 = none. */
59
- activeIndex: number;
60
- progress: {
61
- scanned: number;
62
- total: number;
63
- };
64
- error: string | null;
65
- }
66
- type SearchAction = {
67
- type: 'START';
68
- query: SearchQuery$1;
69
- } | {
70
- type: 'APPEND';
71
- hits: SearchHit[];
72
- scanned: number;
73
- total: number;
74
- } | {
75
- type: 'COMPLETE';
76
- } | {
77
- type: 'ERROR';
78
- message: string;
79
- } | {
80
- type: 'SET_ACTIVE';
81
- index: number;
82
- } | {
83
- type: 'CLEAR';
84
- };
85
- /**
86
- * How hit navigation arrives (forwarded to the Stage's positioned reveal).
87
- * The default is the browser find-bar feel: the hit lands at the top-middle
88
- * of the viewport (`anchor: { y: 0.35 }`), horizontal only moves when the
89
- * hit is off-screen, zoom never changes, smooth tween.
90
- */
91
- interface SearchRevealOptions {
92
- anchor?: RevealAnchor;
93
- behavior?: ScrollBehaviorKind;
94
- }
95
- interface SearchPluginConfig {
96
- /** Arrival defaults for `next()`/`prev()`/`goTo()`. */
97
- reveal?: SearchRevealOptions;
98
- }
99
- /**
100
- * The search plugin is a find SERVICE plus one user-visible search SESSION
101
- * per document. `findAll` is the service; `search` is the session; the
102
- * sidebar, the highlights layer, and next/prev render the session.
103
- */
104
- interface SearchCapability {
105
- /**
106
- * Start a new search (supersedes and aborts any running one). Results
107
- * stream into state slice by slice; the first hit becomes active but the
108
- * camera does NOT move until `next()`/`prev()`/`goTo()`. An empty
109
- * `query.text` is "stop searching" — identical to `clear()`.
110
- */
111
- search(query: SearchQuery$1, exec?: SearchExecOptions): void;
112
- /** Re-run the current query from scratch (used after document mutations). */
113
- rerun(): void;
114
- clear(): void;
115
- /** Advance to the next/previous hit (wraps) and reveal it (see {@link SearchRevealOptions}). */
116
- next(): SearchHit | null;
117
- prev(): SearchHit | null;
118
- /** Jump to a specific hit index and reveal it; `reveal` overrides the plugin defaults. */
119
- goTo(index: number, reveal?: SearchRevealOptions): SearchHit | null;
120
- /** The session's query — what a search box renders. Null when idle. */
121
- query(): SearchQuery$1 | null;
122
- status(): SearchStatus;
123
- hits(): SearchHit[];
124
- hitCount(): number;
125
- activeIndex(): number;
126
- activeHit(): SearchHit | null;
127
- /** Hits on one page — the layer's input. Element identity is stable. */
128
- hitsForPage(pon: PageObjectNumber): SearchHit[];
129
- progress(): {
130
- scanned: number;
131
- total: number;
132
- };
133
- errorMessage(): string | null;
134
- /**
135
- * Run a query to completion and return every hit. Touches NO state — no
136
- * sidebar, no highlights, no camera, no activeIndex; the user's visible
137
- * search is never superseded. The primitive other features build on
138
- * (redact-by-term, link detection, occurrence badges). Scans in natural
139
- * page order; concurrent calls are independent.
140
- */
141
- findAll(query: SearchQuery$1, opts?: SearchFindAllOptions): Promise<SearchHit[]>;
142
- }
143
- declare const SearchToken: import("@embedpdf/core").CapabilityToken<SearchCapability>;
144
- //#endregion
1
+ import { _ as SearchToken, a as SearchSnippet, c as SearchAction, d as SearchFindAllOptions, f as SearchHit, g as SearchStatus, h as SearchState, i as SearchRegexValidation, l as SearchCapability, m as SearchRevealOptions, n as SearchQueryIssue, o as validateSearchQuery, p as SearchPluginConfig, r as SearchQueryValidation, s as validateSearchRegex, t as SearchQuery, u as SearchExecOptions } from "./contract-Cj4xgdMj.js";
145
2
  //#region src/search.plugin.d.ts
146
3
  /**
147
4
  * Document text search over `doc.search` — the engine's budgeted,
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
- import { createCapabilityToken, definePlugin } from "@embedpdf/core";
2
- import { StageToken } from "@embedpdf/plugin-stage";
3
- import { applyRect, boundsOfRects, pageGeometry } from "@embedpdf/core-geometry";
4
- import { EngineError, EngineErrorCode, validateSearchQuery, validateSearchRegex } from "@embedpdf/engine-core/runtime";
1
+ import { n as validateSearchRegex, r as SearchToken, t as validateSearchQuery } from "./contract-YzH14CUn.js";
2
+ import { definePlugin } from "@embedpdf/core";
3
+ import { StageToken } from "@embedpdf/plugin-stage/contract";
4
+ import { applyPoint, boundsOfRects, pageGeometry, textQuadBounds } from "@embedpdf/core-geometry";
5
+ import { EngineError, EngineErrorCode } from "@embedpdf/engine-core/runtime";
5
6
  //#region src/capability.ts
6
7
  const EMPTY = [];
7
8
  /** The browser find-bar feel: hit at the top-middle, no zoom change. */
@@ -29,12 +30,6 @@ const isPermissionDenied = (err) => err instanceof Error && err.name === "Permis
29
30
  function createSearchCapability(ctx, config = {}) {
30
31
  let generation = 0;
31
32
  let inflight = null;
32
- const toContent = (m, b) => applyRect(m, {
33
- x: b.left,
34
- y: b.bottom,
35
- width: b.right - b.left,
36
- height: b.top - b.bottom
37
- });
38
33
  function hitsFromSlice(slice) {
39
34
  const converters = /* @__PURE__ */ new Map();
40
35
  const converterFor = (pon) => {
@@ -48,9 +43,15 @@ function createSearchCapability(ctx, config = {}) {
48
43
  rotation: layout.rotation,
49
44
  userUnit: layout.userUnit
50
45
  }, 1);
46
+ const toPoint = (pt) => applyPoint(pdfToContent, pt);
51
47
  conv = {
52
48
  pageIndex: layout.index,
53
- toRect: (b) => toContent(pdfToContent, b)
49
+ toQuad: (q) => ({
50
+ upperStart: toPoint(q.p1),
51
+ upperEnd: toPoint(q.p2),
52
+ lowerStart: toPoint(q.p3),
53
+ lowerEnd: toPoint(q.p4)
54
+ })
54
55
  };
55
56
  }
56
57
  converters.set(pon, conv);
@@ -60,12 +61,21 @@ function createSearchCapability(ctx, config = {}) {
60
61
  for (const match of slice.matches) {
61
62
  const conv = converterFor(match.pageObjectNumber);
62
63
  if (!conv) continue;
64
+ const segments = match.segments.map((segment) => {
65
+ const quad = conv.toQuad(segment.quad);
66
+ return {
67
+ quad,
68
+ rect: textQuadBounds(quad),
69
+ advance: segment.advance
70
+ };
71
+ });
63
72
  hits.push({
64
73
  pon: match.pageObjectNumber,
65
74
  pageIndex: conv.pageIndex,
66
75
  charStart: match.charStart,
67
76
  charCount: match.charCount,
68
- rects: match.rects.map(conv.toRect),
77
+ segments,
78
+ ...segments.length ? { bounds: boundsOfRects(segments.map((s) => s.rect)) ?? void 0 } : {},
69
79
  ...match.snippet ? { snippet: match.snippet } : {}
70
80
  });
71
81
  }
@@ -174,7 +184,7 @@ function createSearchCapability(ctx, config = {}) {
174
184
  ...reveal
175
185
  };
176
186
  ctx.tryGet(StageToken)?.reveal(hit.pageIndex, {
177
- rect: boundsOfRects(hit.rects) ?? void 0,
187
+ rect: hit.bounds,
178
188
  anchor: arrival.anchor,
179
189
  behavior: arrival.behavior
180
190
  });
@@ -187,6 +197,11 @@ function createSearchCapability(ctx, config = {}) {
187
197
  ctx.dispatch({ type: "CLEAR" });
188
198
  };
189
199
  return {
200
+ canSearch: (mode) => {
201
+ const sec = ctx.doc?.security;
202
+ if (!sec) return false;
203
+ return sec.allows("doc.text.search") && (mode !== "full" || sec.allows("doc.text.copy"));
204
+ },
190
205
  search: (query, exec) => query.text.length === 0 ? clear() : void runSession(query, exec?.startPage),
191
206
  rerun: () => {
192
207
  const { query, status } = ctx.getState();
@@ -239,9 +254,6 @@ function createSearchCapability(ctx, config = {}) {
239
254
  };
240
255
  }
241
256
  //#endregion
242
- //#region src/types.ts
243
- const SearchToken = createCapabilityToken("search");
244
- //#endregion
245
257
  //#region src/effects.ts
246
258
  const RERUN_DELAY_MS = 250;
247
259
  function registerSearchEffects(ctx) {
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","names":[],"sources":["../src/capability.ts","../src/types.ts","../src/effects.ts","../src/reducer.ts","../src/search.plugin.ts"],"sourcesContent":["import type { PageObjectNumber, PluginContext } from '@embedpdf/core';\nimport {\n applyRect,\n boundsOfRects,\n pageGeometry,\n type Rect,\n type RectIn,\n} from '@embedpdf/core-geometry';\nimport { StageToken } from '@embedpdf/plugin-stage';\nimport { EngineError, EngineErrorCode } from '@embedpdf/engine-core/runtime';\nimport type {\n PdfRect,\n SearchMatch,\n SearchMode,\n SearchQuery,\n SearchSlice,\n} from '@embedpdf/engine-core/runtime';\n\nimport type {\n SearchAction,\n SearchCapability,\n SearchHit,\n SearchPluginConfig,\n SearchRevealOptions,\n SearchState,\n} from './types';\n\nconst EMPTY: SearchHit[] = [];\n\n/** The browser find-bar feel: hit at the top-middle, no zoom change. */\nconst DEFAULT_REVEAL: SearchRevealOptions = { anchor: { y: 0.35 }, behavior: 'smooth' };\n\n/** Local engines reject a denied scope before the worker; cloud maps 403. */\nconst isPermissionDenied = (err: unknown): boolean =>\n (err instanceof Error && err.name === 'PermissionDenied') ||\n EngineError.is(err, EngineErrorCode.Forbidden);\n\n/**\n * The search capability: a client-driven cursor loop over the engine's\n * budgeted slices. One generation counter arbitrates: a new `search()` (or\n * `clear()`, or a document mutation via `rerun()`) supersedes the running\n * loop, which aborts its in-flight slice and stops dispatching.\n *\n * Rect conversion happens HERE, once per slice: the engine's PDF-space\n * line rects → content space through the same page geometry the selection\n * plugin uses, so search highlights and selection highlights are pixel-twins.\n *\n * Stage integration is optional and additive: with a Stage present the scan\n * starts at the current page (viewport-first — the hits the user is looking\n * at arrive in the first slice) and `next()`/`prev()`/`goTo()` reveal the\n * hit's page. Without one, navigation still works; the host scrolls.\n */\nexport function createSearchCapability(\n ctx: PluginContext<SearchState, SearchAction>,\n config: SearchPluginConfig = {},\n): SearchCapability {\n let generation = 0;\n let inflight: { abort(reason?: unknown): void } | null = null;\n\n const toContent = (m: ReturnType<typeof pageGeometry>['pdfToContent'], b: PdfRect): Rect =>\n applyRect(\n m as never,\n {\n x: b.left,\n y: b.bottom,\n width: b.right - b.left,\n height: b.top - b.bottom,\n } as RectIn<'pdf'>,\n ) as Rect;\n\n interface PageConverter {\n pageIndex: number;\n toRect: (b: PdfRect) => Rect;\n }\n\n function hitsFromSlice(slice: SearchSlice): SearchHit[] {\n // zoom = 1: content space is scale-free; the layer applies the page\n // transform at render time (the selection idiom).\n const converters = new Map<PageObjectNumber, PageConverter | null>();\n const converterFor = (pon: PageObjectNumber): PageConverter | null => {\n let conv = converters.get(pon);\n if (conv !== undefined) return conv;\n const layout = ctx.document()?.pages.find((p) => p.pageObjectNumber === pon);\n if (!layout) {\n conv = null; // page vanished mid-search (delete); drop its hits\n } else {\n const { pdfToContent } = pageGeometry(\n { crop: layout.boxes.crop, rotation: layout.rotation, userUnit: layout.userUnit },\n 1,\n );\n conv = { pageIndex: layout.index, toRect: (b) => toContent(pdfToContent, b) };\n }\n converters.set(pon, conv);\n return conv;\n };\n\n const hits: SearchHit[] = [];\n for (const match of slice.matches) {\n const conv = converterFor(match.pageObjectNumber);\n if (!conv) continue;\n hits.push({\n pon: match.pageObjectNumber,\n pageIndex: conv.pageIndex,\n charStart: match.charStart,\n charCount: match.charCount,\n rects: match.rects.map(conv.toRect),\n ...(match.snippet ? { snippet: match.snippet } : {}),\n });\n }\n return hits;\n }\n\n /** How a collect() consumer observes the loop — the session dispatches\n * into state, findAll accumulates locally. */\n interface CollectSink {\n /** Called before the first slice AND again on a stale-cursor restart —\n * reset any accumulation. */\n onStart: () => void;\n onSlice: (hits: SearchHit[], scanned: number, total: number) => void;\n /** Superseded / aborted — the loop stops without completing. */\n isCancelled: () => boolean;\n setInflight: (pending: { abort(reason?: unknown): void } | null) => void;\n }\n\n /**\n * THE MECHANISM — one cursor loop over the engine's budgeted slices,\n * shared by the session (`search`) and the session-free service\n * (`findAll`): permission fallback ('full' → 'rects' when snippets are\n * denied, unless the caller pinned a mode), restart-once on a stale\n * cursor, rect conversion per slice. Resolves true on exhaustion, false\n * when cancelled; throws on error.\n */\n async function collect(\n query: SearchQuery,\n opts: { startPage?: PageObjectNumber; mode?: SearchMode },\n sink: CollectSink,\n ): Promise<boolean> {\n const doc = ctx.doc;\n if (!doc) return false;\n\n sink.onStart();\n\n let mode: SearchMode = opts.mode ?? 'full';\n const modePinned = opts.mode !== undefined;\n let cursor: string | undefined;\n let restarted = false;\n for (;;) {\n let slice: SearchSlice;\n try {\n const request = {\n query,\n mode,\n ...(cursor !== undefined\n ? { cursor }\n : opts.startPage !== undefined\n ? { startPage: opts.startPage }\n : {}),\n };\n const pending = doc.search.query(request);\n sink.setInflight(pending);\n slice = await pending;\n } catch (err) {\n if (sink.isCancelled()) return false; // superseded — the abort was ours\n // Snippets denied (no doc.text.copy)? Degrade to rect-only matches\n // instead of failing the whole search — find still works.\n if (!modePinned && mode === 'full' && cursor === undefined && isPermissionDenied(err)) {\n mode = 'rects';\n continue;\n }\n // A stale cursor rejects with InvalidArg — the document changed\n // under the loop, or the engine's session was recycled. Position\n // is lost, the query isn't: restart once from scratch.\n if (cursor !== undefined && !restarted && EngineError.is(err, EngineErrorCode.InvalidArg)) {\n restarted = true;\n cursor = undefined;\n sink.onStart();\n continue;\n }\n throw err;\n }\n if (sink.isCancelled()) return false;\n sink.onSlice(hitsFromSlice(slice), slice.scannedPages, slice.totalPages);\n if (slice.nextCursor === null) {\n sink.setInflight(null);\n return true;\n }\n cursor = slice.nextCursor;\n }\n }\n\n /** THE SESSION — collect() with state as the sink, generation-guarded. */\n async function runSession(query: SearchQuery, startPage?: PageObjectNumber): Promise<void> {\n const gen = ++generation;\n inflight?.abort('superseded');\n inflight = null;\n\n if (!ctx.doc) {\n ctx.dispatch({ type: 'CLEAR' });\n return;\n }\n\n // Viewport-first: begin scanning where the user is looking.\n if (startPage === undefined) {\n const stage = ctx.tryGet(StageToken);\n if (stage) startPage = ctx.document()?.pages[stage.currentPage()]?.pageObjectNumber;\n }\n\n try {\n const complete = await collect(\n query,\n { startPage },\n {\n onStart: () => ctx.dispatch({ type: 'START', query }),\n onSlice: (hits, scanned, total) => ctx.dispatch({ type: 'APPEND', hits, scanned, total }),\n isCancelled: () => gen !== generation,\n setInflight: (pending) => {\n if (gen === generation) inflight = pending;\n },\n },\n );\n if (complete && gen === generation) ctx.dispatch({ type: 'COMPLETE' });\n } catch (err) {\n if (gen !== generation) return;\n ctx.dispatch({ type: 'ERROR', message: err instanceof Error ? err.message : String(err) });\n }\n }\n\n function goTo(index: number, reveal?: SearchRevealOptions): SearchHit | null {\n const { hits } = ctx.getState();\n if (hits.length === 0) return null;\n const wrapped = ((index % hits.length) + hits.length) % hits.length;\n ctx.dispatch({ type: 'SET_ACTIVE', index: wrapped });\n const hit = hits[wrapped];\n // Positioned reveal: the HIT (not just its page) arrives at the anchor —\n // per-call override > plugin config > find-bar default. Zoom never changes.\n const arrival = { ...DEFAULT_REVEAL, ...config.reveal, ...reveal };\n ctx.tryGet(StageToken)?.reveal(hit.pageIndex, {\n rect: boundsOfRects(hit.rects) ?? undefined,\n anchor: arrival.anchor,\n behavior: arrival.behavior,\n });\n return hit;\n }\n\n const clear = () => {\n generation++;\n inflight?.abort('cleared');\n inflight = null;\n ctx.dispatch({ type: 'CLEAR' });\n };\n\n return {\n // An empty text is \"stop searching\", not a query — identical to clear().\n search: (query, exec) =>\n query.text.length === 0 ? clear() : void runSession(query, exec?.startPage),\n\n // Replay the stored query verbatim; the rescan starts viewport-first.\n rerun: () => {\n const { query, status } = ctx.getState();\n if (query && status !== 'idle') void runSession(query);\n },\n\n clear,\n\n next: () => goTo(ctx.getState().activeIndex + 1),\n prev: () => goTo(ctx.getState().activeIndex - 1),\n goTo,\n\n query: () => ctx.getState().query,\n\n findAll: async (query, opts = {}) => {\n if (!ctx.doc) return [];\n const all: SearchHit[] = [];\n const signal = opts.signal;\n // Independent lifecycle: its own abort handle, never the session's\n // generation counter — a findAll can neither supersede the user's\n // visible search nor be superseded by it.\n let pending: { abort(reason?: unknown): void } | null = null;\n const onAbort = () => pending?.abort(signal?.reason);\n signal?.addEventListener('abort', onAbort, { once: true });\n try {\n await collect(\n query,\n { mode: opts.mode },\n {\n onStart: () => {\n all.length = 0;\n },\n onSlice: (hits) => all.push(...hits),\n isCancelled: () => signal?.aborted ?? false,\n setInflight: (p) => {\n pending = p;\n },\n },\n );\n if (signal?.aborted) throw signal.reason ?? new Error('findAll aborted');\n return all;\n } finally {\n signal?.removeEventListener('abort', onAbort);\n }\n },\n\n status: () => ctx.getState().status,\n hits: () => ctx.getState().hits,\n hitCount: () => ctx.getState().hits.length,\n activeIndex: () => ctx.getState().activeIndex,\n activeHit: () => {\n const { hits, activeIndex } = ctx.getState();\n return activeIndex >= 0 && activeIndex < hits.length ? hits[activeIndex] : null;\n },\n hitsForPage: (pon) => {\n const { hits, hitsByPage } = ctx.getState();\n const indices = hitsByPage[pon];\n return indices?.length ? indices.map((i) => hits[i]) : EMPTY;\n },\n progress: () => ctx.getState().progress,\n errorMessage: () => ctx.getState().error,\n };\n}\n","import { createCapabilityToken, type PageObjectNumber } from '@embedpdf/core';\nimport type { Rect } from '@embedpdf/core-geometry';\nimport type { RevealAnchor, ScrollBehaviorKind } from '@embedpdf/plugin-stage';\nimport type { SearchMode, SearchQuery, SearchSnippet } from '@embedpdf/engine-core/runtime';\n\n/**\n * One match, viewer-shaped: the engine's PDF-space line rects converted to\n * CONTENT space (y-down — the same space selection rects live in), plus the\n * page's display index so navigation can `reveal()` without a lookup.\n * `charStart`/`charCount` keep the engine's text-page offsets, so a hit can\n * later seed a selection or a highlight annotation without re-searching.\n */\nexport interface SearchHit {\n pon: PageObjectNumber;\n pageIndex: number;\n charStart: number;\n charCount: number;\n rects: Rect[];\n snippet?: SearchSnippet;\n}\n\n/**\n * `searching` means the slice loop is still walking pages — results are\n * usable the moment they appear (matches stream in viewport-first order).\n */\nexport type SearchStatus = 'idle' | 'searching' | 'complete' | 'error';\n\n/**\n * Execution options for the session `search()` — HOW the scan runs, never\n * part of what the query MEANS (which is why they're not on `SearchQuery`\n * and are never stored or restored into a search box).\n */\nexport interface SearchExecOptions {\n /** Scan origin override. Defaults to the Stage's current page (viewport-first). */\n startPage?: PageObjectNumber;\n}\n\n/** Options for the session-free `findAll()` service. */\nexport interface SearchFindAllOptions {\n /** Abort the scan; the returned promise rejects with `signal.reason`. */\n signal?: AbortSignal;\n /**\n * Pin the slice mode. Default: `'full'` with automatic `'rects'`\n * fallback when snippets are denied — pass `'rects'` when you only need\n * geometry (e.g. redact-by-term) to skip snippet extraction entirely.\n */\n mode?: SearchMode;\n}\n\nexport interface SearchState {\n /**\n * The query in flight/completed, null when idle/cleared — THE stored\n * form of the search intent, the same flat `SearchQuery` the engine\n * matches on, the wire caches by, and a search box renders. Survives\n * any search UI unmounting (results are document-scoped; so is this).\n */\n query: SearchQuery | null;\n status: SearchStatus;\n hits: SearchHit[];\n /** pon → indices into `hits` — the per-page layer's read model. */\n hitsByPage: Record<number, number[]>;\n /** Index into `hits`, -1 = none. */\n activeIndex: number;\n progress: { scanned: number; total: number };\n error: string | null;\n}\n\nexport type SearchAction =\n | { type: 'START'; query: SearchQuery }\n | { type: 'APPEND'; hits: SearchHit[]; scanned: number; total: number }\n | { type: 'COMPLETE' }\n | { type: 'ERROR'; message: string }\n | { type: 'SET_ACTIVE'; index: number }\n | { type: 'CLEAR' };\n\n/**\n * How hit navigation arrives (forwarded to the Stage's positioned reveal).\n * The default is the browser find-bar feel: the hit lands at the top-middle\n * of the viewport (`anchor: { y: 0.35 }`), horizontal only moves when the\n * hit is off-screen, zoom never changes, smooth tween.\n */\nexport interface SearchRevealOptions {\n anchor?: RevealAnchor;\n behavior?: ScrollBehaviorKind;\n}\n\nexport interface SearchPluginConfig {\n /** Arrival defaults for `next()`/`prev()`/`goTo()`. */\n reveal?: SearchRevealOptions;\n}\n\n/**\n * The search plugin is a find SERVICE plus one user-visible search SESSION\n * per document. `findAll` is the service; `search` is the session; the\n * sidebar, the highlights layer, and next/prev render the session.\n */\nexport interface SearchCapability {\n // ── the session: THE user-visible find, one per document ────────────────\n /**\n * Start a new search (supersedes and aborts any running one). Results\n * stream into state slice by slice; the first hit becomes active but the\n * camera does NOT move until `next()`/`prev()`/`goTo()`. An empty\n * `query.text` is \"stop searching\" — identical to `clear()`.\n */\n search(query: SearchQuery, exec?: SearchExecOptions): void;\n /** Re-run the current query from scratch (used after document mutations). */\n rerun(): void;\n clear(): void;\n\n /** Advance to the next/previous hit (wraps) and reveal it (see {@link SearchRevealOptions}). */\n next(): SearchHit | null;\n prev(): SearchHit | null;\n /** Jump to a specific hit index and reveal it; `reveal` overrides the plugin defaults. */\n goTo(index: number, reveal?: SearchRevealOptions): SearchHit | null;\n\n /** The session's query — what a search box renders. Null when idle. */\n query(): SearchQuery | null;\n status(): SearchStatus;\n hits(): SearchHit[];\n hitCount(): number;\n activeIndex(): number;\n activeHit(): SearchHit | null;\n /** Hits on one page — the layer's input. Element identity is stable. */\n hitsForPage(pon: PageObjectNumber): SearchHit[];\n progress(): { scanned: number; total: number };\n errorMessage(): string | null;\n\n // ── the mechanism: session-free find service ─────────────────────────────\n /**\n * Run a query to completion and return every hit. Touches NO state — no\n * sidebar, no highlights, no camera, no activeIndex; the user's visible\n * search is never superseded. The primitive other features build on\n * (redact-by-term, link detection, occurrence badges). Scans in natural\n * page order; concurrent calls are independent.\n */\n findAll(query: SearchQuery, opts?: SearchFindAllOptions): Promise<SearchHit[]>;\n}\n\nexport const SearchToken = createCapabilityToken<SearchCapability>('search');\n","/**\n * Keeps results honest across document mutations. Every confirmed\n * mutation (own or remote) bumps the engine's mutation sequence, which\n * invalidates search cursors AND can change what's findable (redaction,\n * page delete, import). The correct reaction is always the same: re-run\n * the current query from scratch. Reruns are coalesced — a burst of\n * events (multi-part mutation, SSE catch-up) triggers one rescan.\n */\nimport type { EffectContext } from '@embedpdf/core';\n\nimport { SearchToken } from './types';\nimport type { SearchAction, SearchState } from './types';\n\nconst RERUN_DELAY_MS = 250;\n\nexport function registerSearchEffects(ctx: EffectContext<SearchState, SearchAction>): void {\n const doc = ctx.doc;\n if (!doc) return;\n\n let timer: ReturnType<typeof setTimeout> | null = null;\n const unsubscribe = doc.events.subscribe(() => {\n if (ctx.getState().status === 'idle') return;\n if (timer !== null) clearTimeout(timer);\n timer = setTimeout(() => {\n timer = null;\n ctx.get(SearchToken).rerun();\n }, RERUN_DELAY_MS);\n });\n\n ctx.cleanup(() => {\n if (timer !== null) clearTimeout(timer);\n unsubscribe();\n });\n}\n","import type { SearchAction, SearchState } from './types';\n\nexport const initialSearchState = (): SearchState => ({\n query: null,\n status: 'idle',\n hits: [],\n hitsByPage: {},\n activeIndex: -1,\n progress: { scanned: 0, total: 0 },\n error: null,\n});\n\nexport const searchReducer = (state: SearchState, action: SearchAction): SearchState => {\n switch (action.type) {\n case 'START':\n return { ...initialSearchState(), query: action.query, status: 'searching' };\n case 'APPEND': {\n if (action.hits.length === 0) {\n return { ...state, progress: { scanned: action.scanned, total: action.total } };\n }\n const hits = state.hits.concat(action.hits);\n const hitsByPage = { ...state.hitsByPage };\n for (let i = state.hits.length; i < hits.length; i++) {\n const pon = hits[i].pon;\n hitsByPage[pon] = (hitsByPage[pon] ?? []).concat(i);\n }\n return {\n ...state,\n hits,\n hitsByPage,\n // The first hit becomes active (so \"1/N\" reads right) — navigation,\n // not the stream, moves the camera.\n activeIndex: state.activeIndex === -1 ? 0 : state.activeIndex,\n progress: { scanned: action.scanned, total: action.total },\n };\n }\n case 'COMPLETE':\n return { ...state, status: 'complete' };\n case 'ERROR':\n return { ...state, status: 'error', error: action.message };\n case 'SET_ACTIVE':\n return { ...state, activeIndex: action.index };\n case 'CLEAR':\n return initialSearchState();\n default:\n return state;\n }\n};\n","import { definePlugin } from '@embedpdf/core';\nimport { StageToken } from '@embedpdf/plugin-stage';\n\nimport { createSearchCapability } from './capability';\nimport { registerSearchEffects } from './effects';\nimport { initialSearchState, searchReducer } from './reducer';\nimport { SearchToken } from './types';\nimport type { SearchAction, SearchCapability, SearchPluginConfig, SearchState } from './types';\n\n/**\n * Document text search over `doc.search` — the engine's budgeted,\n * cursor-resumable slices. Document-scoped; no pointer handling, so it\n * needs no interaction hub. The Stage is optional: when present, scans\n * start at the current page (viewport-first) and hit navigation reveals\n * the hit's page; without it the host owns scrolling.\n */\nexport const searchPlugin = (config?: SearchPluginConfig) =>\n definePlugin<SearchState, SearchAction, SearchCapability>({\n id: 'search',\n token: SearchToken,\n scope: 'document',\n optional: [StageToken],\n initialState: initialSearchState,\n reduce: searchReducer,\n capability: (ctx) => createSearchCapability(ctx, config),\n effects: registerSearchEffects,\n });\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.js","names":[],"sources":["../src/capability.ts","../src/effects.ts","../src/reducer.ts","../src/search.plugin.ts"],"sourcesContent":["import type { PageObjectNumber, PluginContext } from '@embedpdf/core';\nimport { type PointIn, type TextQuad, applyPoint, boundsOfRects, pageGeometry, textQuadBounds } from '@embedpdf/core-geometry';\nimport { StageToken } from '@embedpdf/plugin-stage/contract';\nimport { EngineError, EngineErrorCode } from '@embedpdf/engine-core/runtime';\nimport type {\n PdfQuad,\n PdfRect,\n SearchMatch,\n SearchMode,\n SearchQuery,\n SearchSlice,\n} from '@embedpdf/engine-core/runtime';\n\nimport type {\n SearchAction,\n SearchCapability,\n SearchHit,\n SearchPluginConfig,\n SearchRevealOptions,\n SearchState,\n} from './types';\n\nconst EMPTY: SearchHit[] = [];\n\n/** The browser find-bar feel: hit at the top-middle, no zoom change. */\nconst DEFAULT_REVEAL: SearchRevealOptions = { anchor: { y: 0.35 }, behavior: 'smooth' };\n\n/** Local engines reject a denied scope before the worker; cloud maps 403. */\nconst isPermissionDenied = (err: unknown): boolean =>\n (err instanceof Error && err.name === 'PermissionDenied') ||\n EngineError.is(err, EngineErrorCode.Forbidden);\n\n/**\n * The search capability: a client-driven cursor loop over the engine's\n * budgeted slices. One generation counter arbitrates: a new `search()` (or\n * `clear()`, or a document mutation via `rerun()`) supersedes the running\n * loop, which aborts its in-flight slice and stops dispatching.\n *\n * Rect conversion happens HERE, once per slice: the engine's PDF-space\n * line rects → content space through the same page geometry the selection\n * plugin uses, so search highlights and selection highlights are pixel-twins.\n *\n * Stage integration is optional and additive: with a Stage present the scan\n * starts at the current page (viewport-first — the hits the user is looking\n * at arrive in the first slice) and `next()`/`prev()`/`goTo()` reveal the\n * hit's page. Without one, navigation still works; the host scrolls.\n */\nexport function createSearchCapability(\n ctx: PluginContext<SearchState, SearchAction>,\n config: SearchPluginConfig = {},\n): SearchCapability {\n let generation = 0;\n let inflight: { abort(reason?: unknown): void } | null = null;\n\n interface PageConverter {\n pageIndex: number;\n toQuad: (q: PdfQuad) => TextQuad;\n }\n\n function hitsFromSlice(slice: SearchSlice): SearchHit[] {\n // zoom = 1: content space is scale-free; the layer applies the page\n // transform at render time (the selection idiom).\n const converters = new Map<PageObjectNumber, PageConverter | null>();\n const converterFor = (pon: PageObjectNumber): PageConverter | null => {\n let conv = converters.get(pon);\n if (conv !== undefined) return conv;\n const layout = ctx.document()?.pages.find((p) => p.pageObjectNumber === pon);\n if (!layout) {\n conv = null; // page vanished mid-search (delete); drop its hits\n } else {\n const { pdfToContent } = pageGeometry(\n { crop: layout.boxes.crop, rotation: layout.rotation, userUnit: layout.userUnit },\n 1,\n );\n // Engine quads carry frame-geometric slot semantics (p1..p4 =\n // upper-start, upper-end, lower-start, lower-end) — a trusted\n // producer, so the y-flip maps corners straight onto their names.\n const toPoint = (pt: { x: number; y: number }) =>\n applyPoint(pdfToContent as never, pt as PointIn<'pdf'>) as { x: number; y: number };\n conv = {\n pageIndex: layout.index,\n toQuad: (q) => ({\n upperStart: toPoint(q.p1),\n upperEnd: toPoint(q.p2),\n lowerStart: toPoint(q.p3),\n lowerEnd: toPoint(q.p4),\n }),\n };\n }\n converters.set(pon, conv);\n return conv;\n };\n\n const hits: SearchHit[] = [];\n for (const match of slice.matches) {\n const conv = converterFor(match.pageObjectNumber);\n if (!conv) continue;\n const segments = match.segments.map((segment) => {\n const quad = conv.toQuad(segment.quad);\n // `rect` is recomputed from the MAPPED quad so the pair can't drift.\n return { quad, rect: textQuadBounds(quad), advance: segment.advance };\n });\n hits.push({\n pon: match.pageObjectNumber,\n pageIndex: conv.pageIndex,\n charStart: match.charStart,\n charCount: match.charCount,\n segments,\n ...(segments.length\n ? { bounds: boundsOfRects(segments.map((s) => s.rect)) ?? undefined }\n : {}),\n ...(match.snippet ? { snippet: match.snippet } : {}),\n });\n }\n return hits;\n }\n\n /** How a collect() consumer observes the loop — the session dispatches\n * into state, findAll accumulates locally. */\n interface CollectSink {\n /** Called before the first slice AND again on a stale-cursor restart —\n * reset any accumulation. */\n onStart: () => void;\n onSlice: (hits: SearchHit[], scanned: number, total: number) => void;\n /** Superseded / aborted — the loop stops without completing. */\n isCancelled: () => boolean;\n setInflight: (pending: { abort(reason?: unknown): void } | null) => void;\n }\n\n /**\n * THE MECHANISM — one cursor loop over the engine's budgeted slices,\n * shared by the session (`search`) and the session-free service\n * (`findAll`): permission fallback ('full' → 'rects' when snippets are\n * denied, unless the caller pinned a mode), restart-once on a stale\n * cursor, rect conversion per slice. Resolves true on exhaustion, false\n * when cancelled; throws on error.\n */\n async function collect(\n query: SearchQuery,\n opts: { startPage?: PageObjectNumber; mode?: SearchMode },\n sink: CollectSink,\n ): Promise<boolean> {\n const doc = ctx.doc;\n if (!doc) return false;\n\n sink.onStart();\n\n let mode: SearchMode = opts.mode ?? 'full';\n const modePinned = opts.mode !== undefined;\n let cursor: string | undefined;\n let restarted = false;\n for (;;) {\n let slice: SearchSlice;\n try {\n const request = {\n query,\n mode,\n ...(cursor !== undefined\n ? { cursor }\n : opts.startPage !== undefined\n ? { startPage: opts.startPage }\n : {}),\n };\n const pending = doc.search.query(request);\n sink.setInflight(pending);\n slice = await pending;\n } catch (err) {\n if (sink.isCancelled()) return false; // superseded — the abort was ours\n // Snippets denied (no doc.text.copy)? Degrade to rect-only matches\n // instead of failing the whole search — find still works.\n if (!modePinned && mode === 'full' && cursor === undefined && isPermissionDenied(err)) {\n mode = 'rects';\n continue;\n }\n // A stale cursor rejects with InvalidArg — the document changed\n // under the loop, or the engine's session was recycled. Position\n // is lost, the query isn't: restart once from scratch.\n if (cursor !== undefined && !restarted && EngineError.is(err, EngineErrorCode.InvalidArg)) {\n restarted = true;\n cursor = undefined;\n sink.onStart();\n continue;\n }\n throw err;\n }\n if (sink.isCancelled()) return false;\n sink.onSlice(hitsFromSlice(slice), slice.scannedPages, slice.totalPages);\n if (slice.nextCursor === null) {\n sink.setInflight(null);\n return true;\n }\n cursor = slice.nextCursor;\n }\n }\n\n /** THE SESSION — collect() with state as the sink, generation-guarded. */\n async function runSession(query: SearchQuery, startPage?: PageObjectNumber): Promise<void> {\n const gen = ++generation;\n inflight?.abort('superseded');\n inflight = null;\n\n if (!ctx.doc) {\n ctx.dispatch({ type: 'CLEAR' });\n return;\n }\n\n // Viewport-first: begin scanning where the user is looking.\n if (startPage === undefined) {\n const stage = ctx.tryGet(StageToken);\n if (stage) startPage = ctx.document()?.pages[stage.currentPage()]?.pageObjectNumber;\n }\n\n try {\n const complete = await collect(\n query,\n { startPage },\n {\n onStart: () => ctx.dispatch({ type: 'START', query }),\n onSlice: (hits, scanned, total) => ctx.dispatch({ type: 'APPEND', hits, scanned, total }),\n isCancelled: () => gen !== generation,\n setInflight: (pending) => {\n if (gen === generation) inflight = pending;\n },\n },\n );\n if (complete && gen === generation) ctx.dispatch({ type: 'COMPLETE' });\n } catch (err) {\n if (gen !== generation) return;\n ctx.dispatch({ type: 'ERROR', message: err instanceof Error ? err.message : String(err) });\n }\n }\n\n function goTo(index: number, reveal?: SearchRevealOptions): SearchHit | null {\n const { hits } = ctx.getState();\n if (hits.length === 0) return null;\n const wrapped = ((index % hits.length) + hits.length) % hits.length;\n ctx.dispatch({ type: 'SET_ACTIVE', index: wrapped });\n const hit = hits[wrapped];\n // Positioned reveal: the HIT (not just its page) arrives at the anchor —\n // per-call override > plugin config > find-bar default. Zoom never changes.\n const arrival = { ...DEFAULT_REVEAL, ...config.reveal, ...reveal };\n ctx.tryGet(StageToken)?.reveal(hit.pageIndex, {\n rect: hit.bounds,\n anchor: arrival.anchor,\n behavior: arrival.behavior,\n });\n return hit;\n }\n\n const clear = () => {\n generation++;\n inflight?.abort('cleared');\n inflight = null;\n ctx.dispatch({ type: 'CLEAR' });\n };\n\n return {\n // The twin. Search is a pure request, so the VERBS carry no client gate\n // (the engine enforces; the ERROR state reports) — the twin exists for\n // affordance hiding. 'full' composes both capabilities because a snippet\n // reproduces document text; the session's own degradation means plain\n // `canSearch()` is exactly \"would `search()` produce hits\".\n canSearch: (mode) => {\n const sec = ctx.doc?.security;\n if (!sec) return false;\n return sec.allows('doc.text.search') && (mode !== 'full' || sec.allows('doc.text.copy'));\n },\n\n // An empty text is \"stop searching\", not a query — identical to clear().\n search: (query, exec) =>\n query.text.length === 0 ? clear() : void runSession(query, exec?.startPage),\n\n // Replay the stored query verbatim; the rescan starts viewport-first.\n rerun: () => {\n const { query, status } = ctx.getState();\n if (query && status !== 'idle') void runSession(query);\n },\n\n clear,\n\n next: () => goTo(ctx.getState().activeIndex + 1),\n prev: () => goTo(ctx.getState().activeIndex - 1),\n goTo,\n\n query: () => ctx.getState().query,\n\n findAll: async (query, opts = {}) => {\n if (!ctx.doc) return [];\n const all: SearchHit[] = [];\n const signal = opts.signal;\n // Independent lifecycle: its own abort handle, never the session's\n // generation counter — a findAll can neither supersede the user's\n // visible search nor be superseded by it.\n let pending: { abort(reason?: unknown): void } | null = null;\n const onAbort = () => pending?.abort(signal?.reason);\n signal?.addEventListener('abort', onAbort, { once: true });\n try {\n await collect(\n query,\n { mode: opts.mode },\n {\n onStart: () => {\n all.length = 0;\n },\n onSlice: (hits) => all.push(...hits),\n isCancelled: () => signal?.aborted ?? false,\n setInflight: (p) => {\n pending = p;\n },\n },\n );\n if (signal?.aborted) throw signal.reason ?? new Error('findAll aborted');\n return all;\n } finally {\n signal?.removeEventListener('abort', onAbort);\n }\n },\n\n status: () => ctx.getState().status,\n hits: () => ctx.getState().hits,\n hitCount: () => ctx.getState().hits.length,\n activeIndex: () => ctx.getState().activeIndex,\n activeHit: () => {\n const { hits, activeIndex } = ctx.getState();\n return activeIndex >= 0 && activeIndex < hits.length ? hits[activeIndex] : null;\n },\n hitsForPage: (pon) => {\n const { hits, hitsByPage } = ctx.getState();\n const indices = hitsByPage[pon];\n return indices?.length ? indices.map((i) => hits[i]) : EMPTY;\n },\n progress: () => ctx.getState().progress,\n errorMessage: () => ctx.getState().error,\n };\n}\n","/**\n * Keeps results honest across document mutations. Every confirmed\n * mutation (own or remote) bumps the engine's mutation sequence, which\n * invalidates search cursors AND can change what's findable (redaction,\n * page delete, import). The correct reaction is always the same: re-run\n * the current query from scratch. Reruns are coalesced — a burst of\n * events (multi-part mutation, SSE catch-up) triggers one rescan.\n */\nimport type { EffectContext } from '@embedpdf/core';\n\nimport { SearchToken } from './types';\nimport type { SearchAction, SearchState } from './types';\n\nconst RERUN_DELAY_MS = 250;\n\nexport function registerSearchEffects(ctx: EffectContext<SearchState, SearchAction>): void {\n const doc = ctx.doc;\n if (!doc) return;\n\n let timer: ReturnType<typeof setTimeout> | null = null;\n const unsubscribe = doc.events.subscribe(() => {\n if (ctx.getState().status === 'idle') return;\n if (timer !== null) clearTimeout(timer);\n timer = setTimeout(() => {\n timer = null;\n ctx.get(SearchToken).rerun();\n }, RERUN_DELAY_MS);\n });\n\n ctx.cleanup(() => {\n if (timer !== null) clearTimeout(timer);\n unsubscribe();\n });\n}\n","import type { SearchAction, SearchState } from './types';\n\nexport const initialSearchState = (): SearchState => ({\n query: null,\n status: 'idle',\n hits: [],\n hitsByPage: {},\n activeIndex: -1,\n progress: { scanned: 0, total: 0 },\n error: null,\n});\n\nexport const searchReducer = (state: SearchState, action: SearchAction): SearchState => {\n switch (action.type) {\n case 'START':\n return { ...initialSearchState(), query: action.query, status: 'searching' };\n case 'APPEND': {\n if (action.hits.length === 0) {\n return { ...state, progress: { scanned: action.scanned, total: action.total } };\n }\n const hits = state.hits.concat(action.hits);\n const hitsByPage = { ...state.hitsByPage };\n for (let i = state.hits.length; i < hits.length; i++) {\n const pon = hits[i].pon;\n hitsByPage[pon] = (hitsByPage[pon] ?? []).concat(i);\n }\n return {\n ...state,\n hits,\n hitsByPage,\n // The first hit becomes active (so \"1/N\" reads right) — navigation,\n // not the stream, moves the camera.\n activeIndex: state.activeIndex === -1 ? 0 : state.activeIndex,\n progress: { scanned: action.scanned, total: action.total },\n };\n }\n case 'COMPLETE':\n return { ...state, status: 'complete' };\n case 'ERROR':\n return { ...state, status: 'error', error: action.message };\n case 'SET_ACTIVE':\n return { ...state, activeIndex: action.index };\n case 'CLEAR':\n return initialSearchState();\n default:\n return state;\n }\n};\n","import { definePlugin } from '@embedpdf/core';\nimport { StageToken } from '@embedpdf/plugin-stage/contract';\n\nimport { createSearchCapability } from './capability';\nimport { registerSearchEffects } from './effects';\nimport { initialSearchState, searchReducer } from './reducer';\nimport { SearchToken } from './types';\nimport type { SearchAction, SearchCapability, SearchPluginConfig, SearchState } from './types';\n\n/**\n * Document text search over `doc.search` — the engine's budgeted,\n * cursor-resumable slices. Document-scoped; no pointer handling, so it\n * needs no interaction hub. The Stage is optional: when present, scans\n * start at the current page (viewport-first) and hit navigation reveals\n * the hit's page; without it the host owns scrolling.\n */\nexport const searchPlugin = (config?: SearchPluginConfig) =>\n definePlugin<SearchState, SearchAction, SearchCapability>({\n id: 'search',\n token: SearchToken,\n scope: 'document',\n optional: [StageToken],\n initialState: initialSearchState,\n reduce: searchReducer,\n capability: (ctx) => createSearchCapability(ctx, config),\n effects: registerSearchEffects,\n });\n"],"mappings":";;;;;;AAsBA,MAAM,QAAqB,CAAC;;AAG5B,MAAM,iBAAsC;CAAE,QAAQ,EAAE,GAAG,IAAK;CAAG,UAAU;AAAS;;AAGtF,MAAM,sBAAsB,QACzB,eAAe,SAAS,IAAI,SAAS,sBACtC,YAAY,GAAG,KAAK,gBAAgB,SAAS;;;;;;;;;;;;;;;;AAiB/C,SAAgB,uBACd,KACA,SAA6B,CAAC,GACZ;CAClB,IAAI,aAAa;CACjB,IAAI,WAAqD;CAOzD,SAAS,cAAc,OAAiC;EAGtD,MAAM,6BAAa,IAAI,IAA4C;EACnE,MAAM,gBAAgB,QAAgD;GACpE,IAAI,OAAO,WAAW,IAAI,GAAG;GAC7B,IAAI,SAAS,KAAA,GAAW,OAAO;GAC/B,MAAM,SAAS,IAAI,SAAS,CAAC,EAAE,MAAM,MAAM,MAAM,EAAE,qBAAqB,GAAG;GAC3E,IAAI,CAAC,QACH,OAAO;QACF;IACL,MAAM,EAAE,iBAAiB,aACvB;KAAE,MAAM,OAAO,MAAM;KAAM,UAAU,OAAO;KAAU,UAAU,OAAO;IAAS,GAChF,CACF;IAIA,MAAM,WAAW,OACf,WAAW,cAAuB,EAAoB;IACxD,OAAO;KACL,WAAW,OAAO;KAClB,SAAS,OAAO;MACd,YAAY,QAAQ,EAAE,EAAE;MACxB,UAAU,QAAQ,EAAE,EAAE;MACtB,YAAY,QAAQ,EAAE,EAAE;MACxB,UAAU,QAAQ,EAAE,EAAE;KACxB;IACF;GACF;GACA,WAAW,IAAI,KAAK,IAAI;GACxB,OAAO;EACT;EAEA,MAAM,OAAoB,CAAC;EAC3B,KAAK,MAAM,SAAS,MAAM,SAAS;GACjC,MAAM,OAAO,aAAa,MAAM,gBAAgB;GAChD,IAAI,CAAC,MAAM;GACX,MAAM,WAAW,MAAM,SAAS,KAAK,YAAY;IAC/C,MAAM,OAAO,KAAK,OAAO,QAAQ,IAAI;IAErC,OAAO;KAAE;KAAM,MAAM,eAAe,IAAI;KAAG,SAAS,QAAQ;IAAQ;GACtE,CAAC;GACD,KAAK,KAAK;IACR,KAAK,MAAM;IACX,WAAW,KAAK;IAChB,WAAW,MAAM;IACjB,WAAW,MAAM;IACjB;IACA,GAAI,SAAS,SACT,EAAE,QAAQ,cAAc,SAAS,KAAK,MAAM,EAAE,IAAI,CAAC,KAAK,KAAA,EAAU,IAClE,CAAC;IACL,GAAI,MAAM,UAAU,EAAE,SAAS,MAAM,QAAQ,IAAI,CAAC;GACpD,CAAC;EACH;EACA,OAAO;CACT;;;;;;;;;CAsBA,eAAe,QACb,OACA,MACA,MACkB;EAClB,MAAM,MAAM,IAAI;EAChB,IAAI,CAAC,KAAK,OAAO;EAEjB,KAAK,QAAQ;EAEb,IAAI,OAAmB,KAAK,QAAQ;EACpC,MAAM,aAAa,KAAK,SAAS,KAAA;EACjC,IAAI;EACJ,IAAI,YAAY;EAChB,SAAS;GACP,IAAI;GACJ,IAAI;IACF,MAAM,UAAU;KACd;KACA;KACA,GAAI,WAAW,KAAA,IACX,EAAE,OAAO,IACT,KAAK,cAAc,KAAA,IACjB,EAAE,WAAW,KAAK,UAAU,IAC5B,CAAC;IACT;IACA,MAAM,UAAU,IAAI,OAAO,MAAM,OAAO;IACxC,KAAK,YAAY,OAAO;IACxB,QAAQ,MAAM;GAChB,SAAS,KAAK;IACZ,IAAI,KAAK,YAAY,GAAG,OAAO;IAG/B,IAAI,CAAC,cAAc,SAAS,UAAU,WAAW,KAAA,KAAa,mBAAmB,GAAG,GAAG;KACrF,OAAO;KACP;IACF;IAIA,IAAI,WAAW,KAAA,KAAa,CAAC,aAAa,YAAY,GAAG,KAAK,gBAAgB,UAAU,GAAG;KACzF,YAAY;KACZ,SAAS,KAAA;KACT,KAAK,QAAQ;KACb;IACF;IACA,MAAM;GACR;GACA,IAAI,KAAK,YAAY,GAAG,OAAO;GAC/B,KAAK,QAAQ,cAAc,KAAK,GAAG,MAAM,cAAc,MAAM,UAAU;GACvE,IAAI,MAAM,eAAe,MAAM;IAC7B,KAAK,YAAY,IAAI;IACrB,OAAO;GACT;GACA,SAAS,MAAM;EACjB;CACF;;CAGA,eAAe,WAAW,OAAoB,WAA6C;EACzF,MAAM,MAAM,EAAE;EACd,UAAU,MAAM,YAAY;EAC5B,WAAW;EAEX,IAAI,CAAC,IAAI,KAAK;GACZ,IAAI,SAAS,EAAE,MAAM,QAAQ,CAAC;GAC9B;EACF;EAGA,IAAI,cAAc,KAAA,GAAW;GAC3B,MAAM,QAAQ,IAAI,OAAO,UAAU;GACnC,IAAI,OAAO,YAAY,IAAI,SAAS,CAAC,EAAE,MAAM,MAAM,YAAY,EAAE,EAAE;EACrE;EAEA,IAAI;GAaF,IAAI,MAZmB,QACrB,OACA,EAAE,UAAU,GACZ;IACE,eAAe,IAAI,SAAS;KAAE,MAAM;KAAS;IAAM,CAAC;IACpD,UAAU,MAAM,SAAS,UAAU,IAAI,SAAS;KAAE,MAAM;KAAU;KAAM;KAAS;IAAM,CAAC;IACxF,mBAAmB,QAAQ;IAC3B,cAAc,YAAY;KACxB,IAAI,QAAQ,YAAY,WAAW;IACrC;GACF,CACF,KACgB,QAAQ,YAAY,IAAI,SAAS,EAAE,MAAM,WAAW,CAAC;EACvE,SAAS,KAAK;GACZ,IAAI,QAAQ,YAAY;GACxB,IAAI,SAAS;IAAE,MAAM;IAAS,SAAS,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;GAAE,CAAC;EAC3F;CACF;CAEA,SAAS,KAAK,OAAe,QAAgD;EAC3E,MAAM,EAAE,SAAS,IAAI,SAAS;EAC9B,IAAI,KAAK,WAAW,GAAG,OAAO;EAC9B,MAAM,WAAY,QAAQ,KAAK,SAAU,KAAK,UAAU,KAAK;EAC7D,IAAI,SAAS;GAAE,MAAM;GAAc,OAAO;EAAQ,CAAC;EACnD,MAAM,MAAM,KAAK;EAGjB,MAAM,UAAU;GAAE,GAAG;GAAgB,GAAG,OAAO;GAAQ,GAAG;EAAO;EACjE,IAAI,OAAO,UAAU,CAAC,EAAE,OAAO,IAAI,WAAW;GAC5C,MAAM,IAAI;GACV,QAAQ,QAAQ;GAChB,UAAU,QAAQ;EACpB,CAAC;EACD,OAAO;CACT;CAEA,MAAM,cAAc;EAClB;EACA,UAAU,MAAM,SAAS;EACzB,WAAW;EACX,IAAI,SAAS,EAAE,MAAM,QAAQ,CAAC;CAChC;CAEA,OAAO;EAML,YAAY,SAAS;GACnB,MAAM,MAAM,IAAI,KAAK;GACrB,IAAI,CAAC,KAAK,OAAO;GACjB,OAAO,IAAI,OAAO,iBAAiB,MAAM,SAAS,UAAU,IAAI,OAAO,eAAe;EACxF;EAGA,SAAS,OAAO,SACd,MAAM,KAAK,WAAW,IAAI,MAAM,IAAI,KAAK,WAAW,OAAO,MAAM,SAAS;EAG5E,aAAa;GACX,MAAM,EAAE,OAAO,WAAW,IAAI,SAAS;GACvC,IAAI,SAAS,WAAW,QAAQ,WAAgB,KAAK;EACvD;EAEA;EAEA,YAAY,KAAK,IAAI,SAAS,CAAC,CAAC,cAAc,CAAC;EAC/C,YAAY,KAAK,IAAI,SAAS,CAAC,CAAC,cAAc,CAAC;EAC/C;EAEA,aAAa,IAAI,SAAS,CAAC,CAAC;EAE5B,SAAS,OAAO,OAAO,OAAO,CAAC,MAAM;GACnC,IAAI,CAAC,IAAI,KAAK,OAAO,CAAC;GACtB,MAAM,MAAmB,CAAC;GAC1B,MAAM,SAAS,KAAK;GAIpB,IAAI,UAAoD;GACxD,MAAM,gBAAgB,SAAS,MAAM,QAAQ,MAAM;GACnD,QAAQ,iBAAiB,SAAS,SAAS,EAAE,MAAM,KAAK,CAAC;GACzD,IAAI;IACF,MAAM,QACJ,OACA,EAAE,MAAM,KAAK,KAAK,GAClB;KACE,eAAe;MACb,IAAI,SAAS;KACf;KACA,UAAU,SAAS,IAAI,KAAK,GAAG,IAAI;KACnC,mBAAmB,QAAQ,WAAW;KACtC,cAAc,MAAM;MAClB,UAAU;KACZ;IACF,CACF;IACA,IAAI,QAAQ,SAAS,MAAM,OAAO,0BAAU,IAAI,MAAM,iBAAiB;IACvE,OAAO;GACT,UAAU;IACR,QAAQ,oBAAoB,SAAS,OAAO;GAC9C;EACF;EAEA,cAAc,IAAI,SAAS,CAAC,CAAC;EAC7B,YAAY,IAAI,SAAS,CAAC,CAAC;EAC3B,gBAAgB,IAAI,SAAS,CAAC,CAAC,KAAK;EACpC,mBAAmB,IAAI,SAAS,CAAC,CAAC;EAClC,iBAAiB;GACf,MAAM,EAAE,MAAM,gBAAgB,IAAI,SAAS;GAC3C,OAAO,eAAe,KAAK,cAAc,KAAK,SAAS,KAAK,eAAe;EAC7E;EACA,cAAc,QAAQ;GACpB,MAAM,EAAE,MAAM,eAAe,IAAI,SAAS;GAC1C,MAAM,UAAU,WAAW;GAC3B,OAAO,SAAS,SAAS,QAAQ,KAAK,MAAM,KAAK,EAAE,IAAI;EACzD;EACA,gBAAgB,IAAI,SAAS,CAAC,CAAC;EAC/B,oBAAoB,IAAI,SAAS,CAAC,CAAC;CACrC;AACF;;;ACjUA,MAAM,iBAAiB;AAEvB,SAAgB,sBAAsB,KAAqD;CACzF,MAAM,MAAM,IAAI;CAChB,IAAI,CAAC,KAAK;CAEV,IAAI,QAA8C;CAClD,MAAM,cAAc,IAAI,OAAO,gBAAgB;EAC7C,IAAI,IAAI,SAAS,CAAC,CAAC,WAAW,QAAQ;EACtC,IAAI,UAAU,MAAM,aAAa,KAAK;EACtC,QAAQ,iBAAiB;GACvB,QAAQ;GACR,IAAI,IAAI,WAAW,CAAC,CAAC,MAAM;EAC7B,GAAG,cAAc;CACnB,CAAC;CAED,IAAI,cAAc;EAChB,IAAI,UAAU,MAAM,aAAa,KAAK;EACtC,YAAY;CACd,CAAC;AACH;;;AC/BA,MAAa,4BAAyC;CACpD,OAAO;CACP,QAAQ;CACR,MAAM,CAAC;CACP,YAAY,CAAC;CACb,aAAa;CACb,UAAU;EAAE,SAAS;EAAG,OAAO;CAAE;CACjC,OAAO;AACT;AAEA,MAAa,iBAAiB,OAAoB,WAAsC;CACtF,QAAQ,OAAO,MAAf;EACE,KAAK,SACH,OAAO;GAAE,GAAG,mBAAmB;GAAG,OAAO,OAAO;GAAO,QAAQ;EAAY;EAC7E,KAAK,UAAU;GACb,IAAI,OAAO,KAAK,WAAW,GACzB,OAAO;IAAE,GAAG;IAAO,UAAU;KAAE,SAAS,OAAO;KAAS,OAAO,OAAO;IAAM;GAAE;GAEhF,MAAM,OAAO,MAAM,KAAK,OAAO,OAAO,IAAI;GAC1C,MAAM,aAAa,EAAE,GAAG,MAAM,WAAW;GACzC,KAAK,IAAI,IAAI,MAAM,KAAK,QAAQ,IAAI,KAAK,QAAQ,KAAK;IACpD,MAAM,MAAM,KAAK,EAAE,CAAC;IACpB,WAAW,QAAQ,WAAW,QAAQ,CAAC,EAAA,CAAG,OAAO,CAAC;GACpD;GACA,OAAO;IACL,GAAG;IACH;IACA;IAGA,aAAa,MAAM,gBAAgB,KAAK,IAAI,MAAM;IAClD,UAAU;KAAE,SAAS,OAAO;KAAS,OAAO,OAAO;IAAM;GAC3D;EACF;EACA,KAAK,YACH,OAAO;GAAE,GAAG;GAAO,QAAQ;EAAW;EACxC,KAAK,SACH,OAAO;GAAE,GAAG;GAAO,QAAQ;GAAS,OAAO,OAAO;EAAQ;EAC5D,KAAK,cACH,OAAO;GAAE,GAAG;GAAO,aAAa,OAAO;EAAM;EAC/C,KAAK,SACH,OAAO,mBAAmB;EAC5B,SACE,OAAO;CACX;AACF;;;;;;;;;;AC/BA,MAAa,gBAAgB,WAC3B,aAA0D;CACxD,IAAI;CACJ,OAAO;CACP,OAAO;CACP,UAAU,CAAC,UAAU;CACrB,cAAc;CACd,QAAQ;CACR,aAAa,QAAQ,uBAAuB,KAAK,MAAM;CACvD,SAAS;AACX,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@embedpdf/plugin-search",
3
- "version": "3.0.0-next.1",
3
+ "version": "3.0.0-next.11",
4
4
  "private": false,
5
5
  "description": "Document text search: drives the engine's budgeted search slices, converts match rects to content space, and integrates with the Stage for viewport-first scanning and reveal-on-navigate.",
6
6
  "type": "module",
@@ -12,16 +12,20 @@
12
12
  "import": "./dist/index.js",
13
13
  "require": "./dist/index.cjs"
14
14
  },
15
+ "./contract": {
16
+ "import": "./dist/contract.js",
17
+ "require": "./dist/contract.cjs"
18
+ },
15
19
  "./package.json": "./package.json"
16
20
  },
17
21
  "files": [
18
22
  "dist"
19
23
  ],
20
24
  "dependencies": {
21
- "@embedpdf/core": "3.0.0-next.1",
22
- "@embedpdf/plugin-stage": "3.0.0-next.1",
23
- "@embedpdf/core-geometry": "3.0.0-next.1",
24
- "@embedpdf/engine-core": "3.0.0-next.1"
25
+ "@embedpdf/plugin-stage": "3.0.0-next.11",
26
+ "@embedpdf/core-geometry": "3.0.0-next.11",
27
+ "@embedpdf/engine-core": "3.0.0-next.11",
28
+ "@embedpdf/core": "3.0.0-next.11"
25
29
  },
26
30
  "devDependencies": {
27
31
  "typescript": "^5.0.0",