web-doc 0.3.0 → 0.4.0

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.
package/README.md CHANGED
@@ -9,7 +9,8 @@
9
9
 
10
10
  > web-doc is a fork of [Zrimo](https://github.com/bnku/zrimo) maintained at
11
11
  > [leonidkuznetsov18/web-doc](https://github.com/leonidkuznetsov18/web-doc).
12
- > It adds page-scoped search (`SearchOptions.pageRange`) and is released
12
+ > It adds page-scoped search (`SearchOptions.pageRange`), an approximate page
13
+ > hint with a fuzzy fallback (`SearchOptions.nearPage` / `fuzzy`) and is released
13
14
  > independently; the runtime API, CSS hooks (`.zrimo-ui`, `--zrimo-*`) and
14
15
  > asset layout are the same as upstream's `@zrimo/viewer`.
15
16
 
@@ -7,3 +7,12 @@ export declare function drawEncodedImage(data: Uint8Array, mimeType: string, tar
7
7
  width: number;
8
8
  height: number;
9
9
  }>;
10
+ /**
11
+ * Decode an encoded image only to read its natural size, in CSS pixels at
12
+ * zoom 1. Uses the same decoder as {@link drawEncodedImage}, so EXIF
13
+ * orientation is applied and the result matches what a render will draw.
14
+ */
15
+ export declare function measureEncodedImage(data: Uint8Array, mimeType: string): Promise<{
16
+ width: number;
17
+ height: number;
18
+ }>;
@@ -27,6 +27,20 @@ export async function drawEncodedImage(data, mimeType, target, options) {
27
27
  decoded.close();
28
28
  }
29
29
  }
30
+ /**
31
+ * Decode an encoded image only to read its natural size, in CSS pixels at
32
+ * zoom 1. Uses the same decoder as {@link drawEncodedImage}, so EXIF
33
+ * orientation is applied and the result matches what a render will draw.
34
+ */
35
+ export async function measureEncodedImage(data, mimeType) {
36
+ const decoded = await decodeImage(new Blob([data.slice()], { type: mimeType }));
37
+ try {
38
+ return { width: decoded.width, height: decoded.height };
39
+ }
40
+ finally {
41
+ decoded.close();
42
+ }
43
+ }
30
44
  async function decodeImage(blob) {
31
45
  if (typeof createImageBitmap === "function") {
32
46
  const bitmap = await createImageBitmap(blob, {
@@ -1,9 +1,11 @@
1
- import type { AdapterOpenContext, DocumentAdapter, DocumentInfo, RenderViewport, ViewerWarning } from "../contracts.js";
1
+ import type { AdapterOpenContext, DocumentAdapter, DocumentInfo, PageSize, RenderViewport, ViewerWarning } from "../contracts.js";
2
2
  type NativeImageFormat = "png" | "jpeg" | "gif" | "webp" | "bmp";
3
3
  interface NativeHandle {
4
4
  readonly kind: "native";
5
5
  readonly format: NativeImageFormat;
6
6
  readonly data: Uint8Array;
7
+ /** Natural size when the browser could decode the image at open time. */
8
+ readonly size: PageSize | undefined;
7
9
  readonly warnings: readonly ViewerWarning[];
8
10
  }
9
11
  interface TiffHandle {
@@ -1,5 +1,5 @@
1
1
  import { abortError, ViewerError } from "../errors.js";
2
- import { drawEncodedImage } from "./bitmap.js";
2
+ import { drawEncodedImage, measureEncodedImage } from "./bitmap.js";
3
3
  export class ImageDocumentAdapter {
4
4
  id = "image";
5
5
  formats = ["png", "jpeg", "gif", "webp", "bmp", "tiff"];
@@ -35,10 +35,14 @@ export class ImageDocumentAdapter {
35
35
  }
36
36
  const format = context.format;
37
37
  const animated = isAnimated(data, format);
38
+ const size = await measureNativeImage(data, format);
39
+ if (context.signal.aborted)
40
+ throw abortError();
38
41
  return {
39
42
  kind: "native",
40
43
  format,
41
44
  data,
45
+ size,
42
46
  warnings: animated
43
47
  ? [
44
48
  {
@@ -51,10 +55,12 @@ export class ImageDocumentAdapter {
51
55
  };
52
56
  }
53
57
  async getInfo(handle) {
58
+ const pageSizes = pageSizesOf(handle);
54
59
  return {
55
60
  format: handle.format,
56
61
  unit: "image",
57
62
  pageCount: handle.kind === "tiff" ? handle.backend.pages.length : 1,
63
+ ...(pageSizes ? { pageSizes } : {}),
58
64
  warnings: handle.warnings,
59
65
  };
60
66
  }
@@ -180,6 +186,30 @@ function requestWorker(worker, payload, transfer, signal, timeoutMs = 30_000) {
180
186
  worker.postMessage(payload, transfer);
181
187
  });
182
188
  }
189
+ /**
190
+ * Natural page geometry for the viewport layout and fit modes. Without it the
191
+ * viewport falls back to a letter-sized page, so fit-to-width scales a photo
192
+ * against a size it does not have and the bitmap overflows its slot.
193
+ */
194
+ function pageSizesOf(handle) {
195
+ if (handle.kind === "tiff")
196
+ return handle.backend.pages.map(({ width, height }) => ({ width, height }));
197
+ return handle.size ? [handle.size] : undefined;
198
+ }
199
+ /**
200
+ * Read the natural size once at open time. A decode failure is not fatal here:
201
+ * the render path reports it with its own error, and the viewport keeps its
202
+ * fallback geometry until then.
203
+ */
204
+ async function measureNativeImage(data, format) {
205
+ try {
206
+ const { width, height } = await measureEncodedImage(data, mimeType(format));
207
+ return width > 0 && height > 0 ? { width, height } : undefined;
208
+ }
209
+ catch {
210
+ return undefined;
211
+ }
212
+ }
183
213
  function mimeType(format) {
184
214
  return format === "jpeg" ? "image/jpeg" : `image/${format}`;
185
215
  }
@@ -1,6 +1,8 @@
1
- import type { AdapterOpenContext, DocumentAdapter, DocumentInfo, RenderViewport, ViewerWarning } from "../contracts.js";
1
+ import type { AdapterOpenContext, DocumentAdapter, DocumentInfo, PageSize, RenderViewport, ViewerWarning } from "../contracts.js";
2
2
  interface SvgHandle {
3
3
  readonly data: Uint8Array;
4
+ /** Intrinsic size from the root `width`/`height` or `viewBox`, when declared. */
5
+ readonly size: PageSize | undefined;
4
6
  readonly warnings: readonly ViewerWarning[];
5
7
  }
6
8
  export declare class SvgDocumentAdapter implements DocumentAdapter<SvgHandle> {
@@ -13,5 +15,12 @@ export declare class SvgDocumentAdapter implements DocumentAdapter<SvgHandle> {
13
15
  close(): void;
14
16
  }
15
17
  export declare function sanitizeSvg(source: string): string;
18
+ /**
19
+ * Intrinsic size of the root `<svg>` in CSS pixels at zoom 1: explicit
20
+ * `width`/`height` win, a `viewBox` stands in for a missing pair, and a
21
+ * document that declares neither has no natural size (the viewport keeps its
22
+ * fallback page geometry).
23
+ */
24
+ export declare function parseSvgSize(source: string): PageSize | undefined;
16
25
  export declare function createSvgAdapter(): SvgDocumentAdapter;
17
26
  export {};
@@ -19,6 +19,7 @@ export class SvgDocumentAdapter {
19
19
  const changed = sanitized !== source;
20
20
  return {
21
21
  data: new TextEncoder().encode(sanitized),
22
+ size: parseSvgSize(sanitized),
22
23
  warnings: changed
23
24
  ? [
24
25
  {
@@ -34,6 +35,7 @@ export class SvgDocumentAdapter {
34
35
  format: "svg",
35
36
  unit: "image",
36
37
  pageCount: 1,
38
+ ...(handle.size ? { pageSizes: [handle.size] } : {}),
37
39
  warnings: handle.warnings,
38
40
  };
39
41
  }
@@ -93,6 +95,61 @@ export function sanitizeSvg(source) {
93
95
  }
94
96
  return new XMLSerializer().serializeToString(document.documentElement);
95
97
  }
98
+ /** CSS absolute units → CSS pixels; percentages and unknown units are ignored. */
99
+ const SVG_LENGTH_UNITS = {
100
+ "": 1,
101
+ px: 1,
102
+ pt: 96 / 72,
103
+ pc: 16,
104
+ in: 96,
105
+ cm: 96 / 2.54,
106
+ mm: 96 / 25.4,
107
+ };
108
+ /**
109
+ * Intrinsic size of the root `<svg>` in CSS pixels at zoom 1: explicit
110
+ * `width`/`height` win, a `viewBox` stands in for a missing pair, and a
111
+ * document that declares neither has no natural size (the viewport keeps its
112
+ * fallback page geometry).
113
+ */
114
+ export function parseSvgSize(source) {
115
+ const root = /<svg\b([^>]*)>/i.exec(source);
116
+ if (!root)
117
+ return undefined;
118
+ const width = parseSvgLength(svgAttribute(root[1] ?? "", "width"));
119
+ const height = parseSvgLength(svgAttribute(root[1] ?? "", "height"));
120
+ if (width && height)
121
+ return { width, height };
122
+ const viewBox = svgAttribute(root[1] ?? "", "viewBox")
123
+ ?.trim()
124
+ .split(/[\s,]+/)
125
+ .map(Number);
126
+ if (viewBox?.length === 4 &&
127
+ viewBox.every(Number.isFinite) &&
128
+ viewBox[2] > 0 &&
129
+ viewBox[3] > 0) {
130
+ // One declared side scales the viewBox aspect; none means viewBox units.
131
+ if (width)
132
+ return { width, height: (width * viewBox[3]) / viewBox[2] };
133
+ if (height)
134
+ return { width: (height * viewBox[2]) / viewBox[3], height };
135
+ return { width: viewBox[2], height: viewBox[3] };
136
+ }
137
+ return undefined;
138
+ }
139
+ function svgAttribute(attributes, name) {
140
+ const match = new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "i").exec(attributes);
141
+ return match?.[1] ?? match?.[2];
142
+ }
143
+ function parseSvgLength(value) {
144
+ const match = /^\s*([0-9]*\.?[0-9]+(?:e[+-]?[0-9]+)?)\s*([a-z%]*)\s*$/i.exec(value ?? "");
145
+ if (!match)
146
+ return undefined;
147
+ const factor = SVG_LENGTH_UNITS[match[2].toLowerCase()];
148
+ if (factor === undefined)
149
+ return undefined;
150
+ const length = Number(match[1]) * factor;
151
+ return Number.isFinite(length) && length > 0 ? length : undefined;
152
+ }
96
153
  export function createSvgAdapter() {
97
154
  return new SvgDocumentAdapter();
98
155
  }
@@ -112,6 +112,7 @@ export interface ViewerOptions {
112
112
  readonly layout?: "continuous" | "single";
113
113
  readonly overscan?: number;
114
114
  readonly translations?: Partial<ViewerTranslations>;
115
+ readonly search?: SearchDefaults;
115
116
  }
116
117
  export interface ViewerClientOptions {
117
118
  readonly fetch?: ViewerFetch;
@@ -216,6 +217,36 @@ export interface ViewerEventMap {
216
217
  readonly viewchange: ViewerState;
217
218
  readonly searchchange: SearchResult | null;
218
219
  }
220
+ /**
221
+ * Tuning for the fuzzy fallback that runs when the exact search finds
222
+ * nothing. Matching is delegated to Fuse.js: the query is compared to each
223
+ * page's text with a bounded edit budget, so spacing, line breaks, list
224
+ * bullets, table separators and typographic punctuation may differ from the
225
+ * source, and every hit maps back to the verbatim page text.
226
+ */
227
+ export interface FuzzySearchOptions {
228
+ /**
229
+ * Fuse.js `threshold`: the edit budget per 32-character chunk of the query,
230
+ * `0` exact to `1` anything. Default `0.3`.
231
+ */
232
+ readonly threshold?: number;
233
+ /**
234
+ * Highest Fuse.js score (`0` perfect, `1` no resemblance) a page may have to
235
+ * count as a match. Default `0.4`; raise it to accept a passage that only
236
+ * partly survives on a page, such as a citation that spans a page break.
237
+ */
238
+ readonly maxScore?: number;
239
+ /** Query characters considered. Default `2000`. */
240
+ readonly maxQueryLength?: number;
241
+ /** Characters of each page's text considered. Default `20000`. */
242
+ readonly maxPageTextLength?: number;
243
+ /**
244
+ * Pages compared per batch. The scan proceeds nearest to `nearPage` first
245
+ * and stops after the first batch with a match, yielding to the event loop
246
+ * between batches. Default `4`.
247
+ */
248
+ readonly pagesPerBatch?: number;
249
+ }
219
250
  export interface SearchOptions {
220
251
  readonly caseSensitive?: boolean;
221
252
  /**
@@ -225,6 +256,24 @@ export interface SearchOptions {
225
256
  * is rejected.
226
257
  */
227
258
  readonly pageRange?: readonly [number, number];
259
+ /**
260
+ * Approximate 0-based page the passage is expected on, for callers whose
261
+ * page numbers come from another pagination (a citation produced from a
262
+ * server-side render of the same file). Clamped to the document. The
263
+ * result's `activeIndex` becomes the match closest to it, and the fuzzy
264
+ * fallback scans pages nearest to it first.
265
+ */
266
+ readonly nearPage?: number;
267
+ /**
268
+ * Fall back to Fuse.js fuzzy matching when the exact search finds nothing.
269
+ * `true` uses the viewer's `search.fuzzy` defaults; an object enables it
270
+ * and overrides them; `false` disables it for this call.
271
+ */
272
+ readonly fuzzy?: boolean | FuzzySearchOptions;
273
+ }
274
+ /** Per-viewer defaults applied to every `search()` call. */
275
+ export interface SearchDefaults {
276
+ readonly fuzzy?: boolean | FuzzySearchOptions;
228
277
  }
229
278
  export interface SearchMatch {
230
279
  readonly pageIndex: number;
@@ -232,10 +281,14 @@ export interface SearchMatch {
232
281
  readonly end: number;
233
282
  readonly text: string;
234
283
  }
284
+ /** How the matches of a result were found. */
285
+ export type SearchStrategy = "exact" | "fuzzy";
235
286
  export interface SearchResult {
236
287
  readonly query: string;
237
288
  readonly matches: readonly SearchMatch[];
238
289
  readonly activeIndex: number;
290
+ /** Present when the result has matches. */
291
+ readonly strategy?: SearchStrategy;
239
292
  }
240
293
  export interface HeadlessRenderOptions {
241
294
  readonly zoom?: number;
@@ -0,0 +1,38 @@
1
+ import type { FuzzySearchOptions, SearchMatch } from "./contracts.js";
2
+ /**
3
+ * Fuzzy matching is delegated to Fuse.js (Bitap with a bounded edit budget per
4
+ * 32-character chunk). It bridges the gaps NFKC + case folding leave open when
5
+ * a query was not copied verbatim from the document: AI-generated citations
6
+ * and OCR'd passages differ from the source in spacing, line breaks, list
7
+ * bullets, table separators and typographic punctuation. Every hit maps back
8
+ * to original UTF-16 offsets, so the viewport highlights the verbatim text.
9
+ */
10
+ export interface ResolvedFuzzySearchOptions {
11
+ readonly threshold: number;
12
+ readonly maxScore: number;
13
+ readonly maxQueryLength: number;
14
+ readonly maxPageTextLength: number;
15
+ readonly pagesPerBatch: number;
16
+ }
17
+ export declare const DEFAULT_FUZZY_SEARCH_OPTIONS: ResolvedFuzzySearchOptions;
18
+ /**
19
+ * Layer fuzzy settings in precedence order (viewer defaults first, then the
20
+ * per-call option). `true` enables the defaults, an object enables and
21
+ * overrides them, `false` disables, `undefined` leaves the previous layer in
22
+ * place. Returns `undefined` when fuzzy matching ends up disabled.
23
+ */
24
+ export declare function resolveFuzzySearchOptions(...layers: readonly (boolean | FuzzySearchOptions | undefined)[]): ResolvedFuzzySearchOptions | undefined;
25
+ export interface FuzzyPageText {
26
+ readonly pageIndex: number;
27
+ readonly text: string;
28
+ }
29
+ /**
30
+ * One match per page whose text holds the query within the edit budget: the
31
+ * span from the first to the last matched character, so the highlight covers
32
+ * the passage as one block. Pages are returned in page order.
33
+ */
34
+ export declare function findFuzzyPageMatches(pages: readonly FuzzyPageText[], query: string, options: ResolvedFuzzySearchOptions, caseSensitive?: boolean): readonly SearchMatch[];
35
+ /** Page indices of `[first, last]` ordered by distance from `nearPage`, ties earlier-first. */
36
+ export declare function pagesNearestFirst(first: number, last: number, nearPage: number | undefined): number[];
37
+ /** Index of the match closest to `nearPage`; the first match when there is no hint. */
38
+ export declare function nearestMatchIndex(matches: readonly SearchMatch[], nearPage: number | undefined): number;
@@ -0,0 +1,114 @@
1
+ import Fuse from "fuse.js";
2
+ export const DEFAULT_FUZZY_SEARCH_OPTIONS = Object.freeze({
3
+ threshold: 0.3,
4
+ maxScore: 0.4,
5
+ maxQueryLength: 2000,
6
+ maxPageTextLength: 20_000,
7
+ pagesPerBatch: 4,
8
+ });
9
+ /**
10
+ * Layer fuzzy settings in precedence order (viewer defaults first, then the
11
+ * per-call option). `true` enables the defaults, an object enables and
12
+ * overrides them, `false` disables, `undefined` leaves the previous layer in
13
+ * place. Returns `undefined` when fuzzy matching ends up disabled.
14
+ */
15
+ export function resolveFuzzySearchOptions(...layers) {
16
+ let enabled = false;
17
+ let resolved = DEFAULT_FUZZY_SEARCH_OPTIONS;
18
+ for (const layer of layers) {
19
+ if (layer === undefined)
20
+ continue;
21
+ if (typeof layer === "boolean") {
22
+ enabled = layer;
23
+ continue;
24
+ }
25
+ enabled = true;
26
+ resolved = {
27
+ threshold: unitInterval(layer.threshold, resolved.threshold),
28
+ maxScore: unitInterval(layer.maxScore, resolved.maxScore),
29
+ maxQueryLength: positiveInteger(layer.maxQueryLength, resolved.maxQueryLength),
30
+ maxPageTextLength: positiveInteger(layer.maxPageTextLength, resolved.maxPageTextLength),
31
+ pagesPerBatch: positiveInteger(layer.pagesPerBatch, resolved.pagesPerBatch),
32
+ };
33
+ }
34
+ return enabled ? resolved : undefined;
35
+ }
36
+ /**
37
+ * One match per page whose text holds the query within the edit budget: the
38
+ * span from the first to the last matched character, so the highlight covers
39
+ * the passage as one block. Pages are returned in page order.
40
+ */
41
+ export function findFuzzyPageMatches(pages, query, options, caseSensitive = false) {
42
+ const pattern = query.slice(0, options.maxQueryLength);
43
+ if (!pattern.trim())
44
+ return [];
45
+ const items = pages.map((page) => ({
46
+ pageIndex: page.pageIndex,
47
+ text: page.text.slice(0, options.maxPageTextLength),
48
+ }));
49
+ const fuse = new Fuse(items, {
50
+ keys: ["text"],
51
+ isCaseSensitive: caseSensitive,
52
+ ignoreDiacritics: false,
53
+ includeMatches: true,
54
+ includeScore: true,
55
+ // A citation can sit anywhere on the page; Fuse's location bias would
56
+ // otherwise penalize matches far from the start of the text.
57
+ ignoreLocation: true,
58
+ // Long page text must not dilute the score of a match inside it.
59
+ ignoreFieldNorm: true,
60
+ threshold: options.threshold,
61
+ minMatchCharLength: 3,
62
+ shouldSort: false,
63
+ });
64
+ const matches = [];
65
+ for (const result of fuse.search(pattern)) {
66
+ if ((result.score ?? 1) > options.maxScore)
67
+ continue;
68
+ const indices = result.matches?.[0]?.indices ?? [];
69
+ if (indices.length === 0)
70
+ continue;
71
+ let start = Number.POSITIVE_INFINITY;
72
+ let end = 0;
73
+ for (const [first, last] of indices) {
74
+ start = Math.min(start, first);
75
+ end = Math.max(end, last + 1);
76
+ }
77
+ const text = result.item.text.slice(start, end);
78
+ matches.push({ pageIndex: result.item.pageIndex, start, end, text });
79
+ }
80
+ return matches.sort((a, b) => a.pageIndex - b.pageIndex);
81
+ }
82
+ /** Page indices of `[first, last]` ordered by distance from `nearPage`, ties earlier-first. */
83
+ export function pagesNearestFirst(first, last, nearPage) {
84
+ const pages = Array.from({ length: last - first + 1 }, (_, i) => first + i);
85
+ if (nearPage === undefined)
86
+ return pages;
87
+ return pages.sort((a, b) => Math.abs(a - nearPage) - Math.abs(b - nearPage) || a - b);
88
+ }
89
+ /** Index of the match closest to `nearPage`; the first match when there is no hint. */
90
+ export function nearestMatchIndex(matches, nearPage) {
91
+ if (matches.length === 0)
92
+ return -1;
93
+ if (nearPage === undefined)
94
+ return 0;
95
+ let best = 0;
96
+ for (let index = 1; index < matches.length; index += 1)
97
+ if (Math.abs(matches[index].pageIndex - nearPage) <
98
+ Math.abs(matches[best].pageIndex - nearPage))
99
+ best = index;
100
+ return best;
101
+ }
102
+ function unitInterval(value, fallback) {
103
+ return value !== undefined &&
104
+ Number.isFinite(value) &&
105
+ value >= 0 &&
106
+ value <= 1
107
+ ? value
108
+ : fallback;
109
+ }
110
+ function positiveInteger(value, fallback) {
111
+ return value !== undefined && Number.isInteger(value) && value > 0
112
+ ? value
113
+ : fallback;
114
+ }
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ export * from "./errors.js";
5
5
  export * from "./format.js";
6
6
  export * from "./limits.js";
7
7
  export * from "./interaction.js";
8
+ export * from "./fuzzy-search.js";
8
9
  export * from "./render-scheduler.js";
9
10
  export * from "./i18n.js";
10
11
  export * from "./font-manifest.js";
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ export * from "./errors.js";
5
5
  export * from "./format.js";
6
6
  export * from "./limits.js";
7
7
  export * from "./interaction.js";
8
+ export * from "./fuzzy-search.js";
8
9
  export * from "./render-scheduler.js";
9
10
  export * from "./i18n.js";
10
11
  export * from "./font-manifest.js";
package/dist/viewer.js CHANGED
@@ -2,6 +2,7 @@ import { linkedAbortController } from "./abort.js";
2
2
  import { detectFormat } from "./detect.js";
3
3
  import { abortError, normalizeError, ViewerError } from "./errors.js";
4
4
  import { cellRangeToTsv, cellRangesToTsv, findNormalizedMatches, normalizeCellRange, } from "./interaction.js";
5
+ import { findFuzzyPageMatches, nearestMatchIndex, pagesNearestFirst, resolveFuzzySearchOptions, } from "./fuzzy-search.js";
5
6
  import { enforceContainerLimits, resolveLimits } from "./limits.js";
6
7
  import { loadDocumentSource } from "./source.js";
7
8
  import { AdaptiveViewport } from "./viewport.js";
@@ -386,6 +387,9 @@ export class DocumentViewer {
386
387
  // Validated before the in-flight search is cancelled so a rejected range
387
388
  // leaves the current result and highlights untouched.
388
389
  const [firstPage, lastPage] = resolveSearchPageRange(info.pageCount, options.pageRange);
390
+ const nearPage = resolveNearPage(firstPage, lastPage, options.nearPage);
391
+ const fuzzy = resolveFuzzySearchOptions(this.#options.search?.fuzzy, options.fuzzy);
392
+ const caseSensitive = options.caseSensitive ?? false;
389
393
  this.#activeSearch?.abort();
390
394
  const controller = new AbortController();
391
395
  this.#activeSearch = controller;
@@ -396,19 +400,45 @@ export class DocumentViewer {
396
400
  return immutableSearchResult({ query, matches: [], activeIndex: -1 });
397
401
  }
398
402
  const matches = [];
403
+ let strategy = "exact";
399
404
  try {
405
+ const texts = new Map();
400
406
  for (let pageIndex = firstPage; pageIndex <= lastPage; pageIndex += 1) {
401
407
  if (controller.signal.aborted)
402
408
  throw abortError();
403
409
  const text = await this.getPageText(pageIndex, controller.signal);
404
- matches.push(...findNormalizedMatches(text, cleanQuery, pageIndex, options.caseSensitive ?? false));
410
+ texts.set(pageIndex, text);
411
+ matches.push(...findNormalizedMatches(text, cleanQuery, pageIndex, caseSensitive));
412
+ }
413
+ if (matches.length === 0 && fuzzy) {
414
+ // The page texts are already in memory, so the fallback is CPU only.
415
+ // Pages are compared nearest to the hint first, a batch at a time,
416
+ // and the scan stops at the first batch that holds the passage; a
417
+ // yield between batches keeps a long document from freezing the UI.
418
+ const order = pagesNearestFirst(firstPage, lastPage, nearPage);
419
+ for (let offset = 0; offset < order.length && matches.length === 0; offset += fuzzy.pagesPerBatch) {
420
+ if (offset > 0)
421
+ await yieldToEventLoop();
422
+ if (controller.signal.aborted)
423
+ throw abortError();
424
+ const batch = order
425
+ .slice(offset, offset + fuzzy.pagesPerBatch)
426
+ .map((pageIndex) => ({
427
+ pageIndex,
428
+ text: texts.get(pageIndex) ?? "",
429
+ }));
430
+ matches.push(...findFuzzyPageMatches(batch, cleanQuery, fuzzy, caseSensitive));
431
+ }
432
+ if (matches.length > 0)
433
+ strategy = "fuzzy";
405
434
  }
406
435
  if (generation !== this.#searchGeneration)
407
436
  throw abortError();
408
437
  const result = immutableSearchResult({
409
438
  query,
410
439
  matches,
411
- activeIndex: matches.length > 0 ? 0 : -1,
440
+ activeIndex: nearestMatchIndex(matches, nearPage),
441
+ ...(matches.length > 0 ? { strategy } : {}),
412
442
  });
413
443
  this.#searchResult = result;
414
444
  if (result.activeIndex >= 0)
@@ -783,6 +813,19 @@ function resolveSearchPageRange(pageCount, pageRange) {
783
813
  assertPageIndex(last, pageCount);
784
814
  return first <= last ? [first, last] : [last, first];
785
815
  }
816
+ /**
817
+ * Clamp the approximate page hint into the scanned window. A hint that comes
818
+ * from another pagination of the same file is allowed to overshoot; rejecting
819
+ * it would defeat its purpose.
820
+ */
821
+ function resolveNearPage(firstPage, lastPage, nearPage) {
822
+ if (nearPage === undefined || !Number.isFinite(nearPage))
823
+ return undefined;
824
+ return Math.min(lastPage, Math.max(firstPage, Math.trunc(nearPage)));
825
+ }
826
+ function yieldToEventLoop() {
827
+ return new Promise((resolve) => setTimeout(resolve, 0));
828
+ }
786
829
  function assertPageIndex(pageIndex, pageCount) {
787
830
  if (!Number.isInteger(pageIndex) || pageIndex < 0 || pageIndex >= pageCount)
788
831
  throw new ViewerError("render-failed", "Page index is out of range", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "web-doc",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "web-doc — embeddable browser-only document viewer with Rust/WASM adapters (a fork of Zrimo)",
5
5
  "keywords": [
6
6
  "document-viewer",
@@ -69,6 +69,7 @@
69
69
  },
70
70
  "dependencies": {
71
71
  "@silurus/ooxml": "0.72.2",
72
+ "fuse.js": "7.5.0",
72
73
  "pdfjs-dist": "6.2.108"
73
74
  }
74
75
  }