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 +2 -1
- package/dist/adapters/bitmap.d.ts +9 -0
- package/dist/adapters/bitmap.js +14 -0
- package/dist/adapters/image.d.ts +3 -1
- package/dist/adapters/image.js +31 -1
- package/dist/adapters/svg.d.ts +10 -1
- package/dist/adapters/svg.js +57 -0
- package/dist/contracts.d.ts +53 -0
- package/dist/fuzzy-search.d.ts +38 -0
- package/dist/fuzzy-search.js +114 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/viewer.js +45 -2
- package/package.json +2 -1
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`)
|
|
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
|
+
}>;
|
package/dist/adapters/bitmap.js
CHANGED
|
@@ -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, {
|
package/dist/adapters/image.d.ts
CHANGED
|
@@ -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 {
|
package/dist/adapters/image.js
CHANGED
|
@@ -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
|
}
|
package/dist/adapters/svg.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/adapters/svg.js
CHANGED
|
@@ -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
|
}
|
package/dist/contracts.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
+
"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
|
}
|