wolfy-reader 0.1.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/LICENSE +21 -0
- package/README.md +277 -0
- package/dist/core/book.d.ts +75 -0
- package/dist/core/book.d.ts.map +1 -0
- package/dist/core/book.js +2 -0
- package/dist/core/book.js.map +1 -0
- package/dist/core/errors.d.ts +17 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +29 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/format.d.ts +13 -0
- package/dist/core/format.d.ts.map +1 -0
- package/dist/core/format.js +2 -0
- package/dist/core/format.js.map +1 -0
- package/dist/core/graphemes.d.ts +36 -0
- package/dist/core/graphemes.d.ts.map +1 -0
- package/dist/core/graphemes.js +62 -0
- package/dist/core/graphemes.js.map +1 -0
- package/dist/core/index.d.ts +11 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +6 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/lookup.d.ts +10 -0
- package/dist/core/lookup.d.ts.map +1 -0
- package/dist/core/lookup.js +16 -0
- package/dist/core/lookup.js.map +1 -0
- package/dist/core/open.d.ts +12 -0
- package/dist/core/open.d.ts.map +1 -0
- package/dist/core/open.js +23 -0
- package/dist/core/open.js.map +1 -0
- package/dist/core/position.d.ts +121 -0
- package/dist/core/position.d.ts.map +1 -0
- package/dist/core/position.js +327 -0
- package/dist/core/position.js.map +1 -0
- package/dist/core/reading-text.d.ts +77 -0
- package/dist/core/reading-text.d.ts.map +1 -0
- package/dist/core/reading-text.js +144 -0
- package/dist/core/reading-text.js.map +1 -0
- package/dist/core/source.d.ts +17 -0
- package/dist/core/source.d.ts.map +1 -0
- package/dist/core/source.js +39 -0
- package/dist/core/source.js.map +1 -0
- package/dist/core/storage.d.ts +7 -0
- package/dist/core/storage.d.ts.map +1 -0
- package/dist/core/storage.js +2 -0
- package/dist/core/storage.js.map +1 -0
- package/dist/core/text.d.ts +18 -0
- package/dist/core/text.d.ts.map +1 -0
- package/dist/core/text.js +32 -0
- package/dist/core/text.js.map +1 -0
- package/dist/formats/epub/href.d.ts +14 -0
- package/dist/formats/epub/href.d.ts.map +1 -0
- package/dist/formats/epub/href.js +52 -0
- package/dist/formats/epub/href.js.map +1 -0
- package/dist/formats/epub/index.d.ts +3 -0
- package/dist/formats/epub/index.d.ts.map +1 -0
- package/dist/formats/epub/index.js +223 -0
- package/dist/formats/epub/index.js.map +1 -0
- package/dist/formats/epub/opf.d.ts +27 -0
- package/dist/formats/epub/opf.d.ts.map +1 -0
- package/dist/formats/epub/opf.js +110 -0
- package/dist/formats/epub/opf.js.map +1 -0
- package/dist/formats/epub/toc.d.ts +6 -0
- package/dist/formats/epub/toc.d.ts.map +1 -0
- package/dist/formats/epub/toc.js +88 -0
- package/dist/formats/epub/toc.js.map +1 -0
- package/dist/formats/fb2/index.d.ts +13 -0
- package/dist/formats/fb2/index.d.ts.map +1 -0
- package/dist/formats/fb2/index.js +317 -0
- package/dist/formats/fb2/index.js.map +1 -0
- package/dist/formats/index.d.ts +4 -0
- package/dist/formats/index.d.ts.map +1 -0
- package/dist/formats/index.js +4 -0
- package/dist/formats/index.js.map +1 -0
- package/dist/formats/text/index.d.ts +11 -0
- package/dist/formats/text/index.d.ts.map +1 -0
- package/dist/formats/text/index.js +201 -0
- package/dist/formats/text/index.js.map +1 -0
- package/dist/formats/xml.d.ts +33 -0
- package/dist/formats/xml.d.ts.map +1 -0
- package/dist/formats/xml.js +321 -0
- package/dist/formats/xml.js.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/layout/chunk.d.ts +51 -0
- package/dist/layout/chunk.d.ts.map +1 -0
- package/dist/layout/chunk.js +131 -0
- package/dist/layout/chunk.js.map +1 -0
- package/dist/layout/index.d.ts +238 -0
- package/dist/layout/index.d.ts.map +1 -0
- package/dist/layout/index.js +476 -0
- package/dist/layout/index.js.map +1 -0
- package/dist/reader/index.d.ts +231 -0
- package/dist/reader/index.d.ts.map +1 -0
- package/dist/reader/index.js +958 -0
- package/dist/reader/index.js.map +1 -0
- package/dist/reader/input.d.ts +69 -0
- package/dist/reader/input.d.ts.map +1 -0
- package/dist/reader/input.js +75 -0
- package/dist/reader/input.js.map +1 -0
- package/dist/search/extract.d.ts +51 -0
- package/dist/search/extract.d.ts.map +1 -0
- package/dist/search/extract.js +401 -0
- package/dist/search/extract.js.map +1 -0
- package/dist/search/index.d.ts +11 -0
- package/dist/search/index.d.ts.map +1 -0
- package/dist/search/index.js +11 -0
- package/dist/search/index.js.map +1 -0
- package/dist/search/matcher.d.ts +56 -0
- package/dist/search/matcher.d.ts.map +1 -0
- package/dist/search/matcher.js +128 -0
- package/dist/search/matcher.js.map +1 -0
- package/dist/search/normalize.d.ts +48 -0
- package/dist/search/normalize.d.ts.map +1 -0
- package/dist/search/normalize.js +103 -0
- package/dist/search/normalize.js.map +1 -0
- package/dist/view/allowlist.d.ts +59 -0
- package/dist/view/allowlist.d.ts.map +1 -0
- package/dist/view/allowlist.js +198 -0
- package/dist/view/allowlist.js.map +1 -0
- package/dist/view/appearance.d.ts +137 -0
- package/dist/view/appearance.d.ts.map +1 -0
- package/dist/view/appearance.js +274 -0
- package/dist/view/appearance.js.map +1 -0
- package/dist/view/css.d.ts +24 -0
- package/dist/view/css.d.ts.map +1 -0
- package/dist/view/css.js +153 -0
- package/dist/view/css.js.map +1 -0
- package/dist/view/frame.d.ts +54 -0
- package/dist/view/frame.d.ts.map +1 -0
- package/dist/view/frame.js +980 -0
- package/dist/view/frame.js.map +1 -0
- package/dist/view/host.d.ts +160 -0
- package/dist/view/host.d.ts.map +1 -0
- package/dist/view/host.js +385 -0
- package/dist/view/host.js.map +1 -0
- package/dist/view/index.d.ts +12 -0
- package/dist/view/index.d.ts.map +1 -0
- package/dist/view/index.js +7 -0
- package/dist/view/index.js.map +1 -0
- package/dist/view/protocol.d.ts +220 -0
- package/dist/view/protocol.d.ts.map +1 -0
- package/dist/view/protocol.js +246 -0
- package/dist/view/protocol.js.map +1 -0
- package/dist/view/reference.d.ts +18 -0
- package/dist/view/reference.d.ts.map +1 -0
- package/dist/view/reference.js +25 -0
- package/dist/view/reference.js.map +1 -0
- package/dist/view/resources.d.ts +51 -0
- package/dist/view/resources.d.ts.map +1 -0
- package/dist/view/resources.js +242 -0
- package/dist/view/resources.js.map +1 -0
- package/dist/view/sanitize.d.ts +32 -0
- package/dist/view/sanitize.d.ts.map +1 -0
- package/dist/view/sanitize.js +226 -0
- package/dist/view/sanitize.js.map +1 -0
- package/dist/zip/central.d.ts +12 -0
- package/dist/zip/central.d.ts.map +1 -0
- package/dist/zip/central.js +72 -0
- package/dist/zip/central.js.map +1 -0
- package/dist/zip/crc32.d.ts +3 -0
- package/dist/zip/crc32.d.ts.map +1 -0
- package/dist/zip/crc32.js +28 -0
- package/dist/zip/crc32.js.map +1 -0
- package/dist/zip/eocd.d.ts +9 -0
- package/dist/zip/eocd.d.ts.map +1 -0
- package/dist/zip/eocd.js +84 -0
- package/dist/zip/eocd.js.map +1 -0
- package/dist/zip/errors.d.ts +19 -0
- package/dist/zip/errors.d.ts.map +1 -0
- package/dist/zip/errors.js +37 -0
- package/dist/zip/errors.js.map +1 -0
- package/dist/zip/index.d.ts +18 -0
- package/dist/zip/index.d.ts.map +1 -0
- package/dist/zip/index.js +78 -0
- package/dist/zip/index.js.map +1 -0
- package/dist/zip/inflate.d.ts +2 -0
- package/dist/zip/inflate.d.ts.map +1 -0
- package/dist/zip/inflate.js +43 -0
- package/dist/zip/inflate.js.map +1 -0
- package/dist/zip/source.d.ts +15 -0
- package/dist/zip/source.d.ts.map +1 -0
- package/dist/zip/source.js +34 -0
- package/dist/zip/source.js.map +1 -0
- package/package.json +75 -0
- package/src/core/book.ts +80 -0
- package/src/core/errors.ts +31 -0
- package/src/core/format.ts +14 -0
- package/src/core/graphemes.ts +73 -0
- package/src/core/index.ts +27 -0
- package/src/core/lookup.ts +16 -0
- package/src/core/open.ts +31 -0
- package/src/core/position.ts +485 -0
- package/src/core/reading-text.ts +200 -0
- package/src/core/source.ts +61 -0
- package/src/core/storage.ts +6 -0
- package/src/core/text.ts +35 -0
- package/src/formats/epub/href.ts +54 -0
- package/src/formats/epub/index.ts +235 -0
- package/src/formats/epub/opf.ts +143 -0
- package/src/formats/epub/toc.ts +120 -0
- package/src/formats/fb2/index.ts +348 -0
- package/src/formats/index.ts +3 -0
- package/src/formats/text/index.ts +208 -0
- package/src/formats/xml.ts +326 -0
- package/src/index.ts +26 -0
- package/src/layout/chunk.ts +169 -0
- package/src/layout/index.ts +573 -0
- package/src/reader/index.ts +1206 -0
- package/src/reader/input.ts +122 -0
- package/src/search/extract.ts +403 -0
- package/src/search/index.ts +10 -0
- package/src/search/matcher.ts +187 -0
- package/src/search/normalize.ts +122 -0
- package/src/view/allowlist.ts +273 -0
- package/src/view/appearance.ts +380 -0
- package/src/view/css.ts +172 -0
- package/src/view/frame.ts +1018 -0
- package/src/view/host.ts +527 -0
- package/src/view/index.ts +24 -0
- package/src/view/protocol.ts +412 -0
- package/src/view/reference.ts +30 -0
- package/src/view/resources.ts +265 -0
- package/src/view/sanitize.ts +266 -0
- package/src/zip/central.ts +85 -0
- package/src/zip/crc32.ts +30 -0
- package/src/zip/eocd.ts +97 -0
- package/src/zip/errors.ts +41 -0
- package/src/zip/index.ts +114 -0
- package/src/zip/inflate.ts +41 -0
- package/src/zip/source.ts +51 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The headless full-text matcher. Normalized literal substring search over a
|
|
3
|
+
* section's extracted text, yielding jumpable hits. Imports only `src/core`
|
|
4
|
+
* (`capturePosition`, `Position`, `Book`, `Section`) and platform primitives, so it
|
|
5
|
+
* runs under `node:test` with no browser.
|
|
6
|
+
*
|
|
7
|
+
* The whole-book scan is a lazy async generator: it decodes and scans one section at
|
|
8
|
+
* a time and yields hits as it goes, so the book is never buffered whole and a
|
|
9
|
+
* consumer that `break`s stops the scan (the generator's cleanup runs on return).
|
|
10
|
+
*/
|
|
11
|
+
import { capturePosition, type Book, type Position, type Section } from '../core/index.ts';
|
|
12
|
+
import { collapseWhitespace as collapse } from '../core/text.ts';
|
|
13
|
+
|
|
14
|
+
import { extractSectionText } from './extract.ts';
|
|
15
|
+
import { normalizeQuery, normalizeText } from './normalize.ts';
|
|
16
|
+
|
|
17
|
+
/** One search hit: the matched text, a clean context window, and a jumpable position. */
|
|
18
|
+
export interface SearchHit {
|
|
19
|
+
/** The verbatim matched run, in the section's raw text. */
|
|
20
|
+
readonly text: string;
|
|
21
|
+
/**
|
|
22
|
+
* The match surrounded by context, trimmed to whole words at both ends via
|
|
23
|
+
* `Intl.Segmenter` — no leading/trailing partial word.
|
|
24
|
+
*/
|
|
25
|
+
readonly context: string;
|
|
26
|
+
/**
|
|
27
|
+
* A content-addressed `Position` at the match, built with `capturePosition`. Pass
|
|
28
|
+
* it to `reader.goTo(hit.position)` to land on the hit's page.
|
|
29
|
+
*/
|
|
30
|
+
readonly position: Position;
|
|
31
|
+
/** 0-based index of the section this hit falls in, in reading order. */
|
|
32
|
+
readonly sectionIndex: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SearchOptions {
|
|
36
|
+
/**
|
|
37
|
+
* Characters of surrounding context to aim for on each side of the match before
|
|
38
|
+
* word-boundary trimming. Default 40.
|
|
39
|
+
*/
|
|
40
|
+
readonly contextRadius?: number;
|
|
41
|
+
/** Locale for word segmentation of the context. Defaults to the runtime default. */
|
|
42
|
+
readonly locale?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const DEFAULT_CONTEXT_RADIUS = 40;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* All hits for `query` within one section's already-extracted `rawText`. Pure over
|
|
49
|
+
* strings — no I/O — so it is the unit the section scan and the tests both drive.
|
|
50
|
+
* A blank (whitespace-only or empty) query yields nothing.
|
|
51
|
+
*/
|
|
52
|
+
export function* matchText(
|
|
53
|
+
rawText: string,
|
|
54
|
+
query: string,
|
|
55
|
+
sectionId: string,
|
|
56
|
+
sectionIndex: number,
|
|
57
|
+
options: SearchOptions = {},
|
|
58
|
+
): Generator<SearchHit> {
|
|
59
|
+
const needle = normalizeQuery(query);
|
|
60
|
+
if (needle === '') return;
|
|
61
|
+
|
|
62
|
+
const { text: haystack, map } = normalizeText(rawText);
|
|
63
|
+
const radius = options.contextRadius ?? DEFAULT_CONTEXT_RADIUS;
|
|
64
|
+
|
|
65
|
+
let from = 0;
|
|
66
|
+
for (;;) {
|
|
67
|
+
const at = haystack.indexOf(needle, from);
|
|
68
|
+
if (at === -1) return;
|
|
69
|
+
const end = at + needle.length;
|
|
70
|
+
|
|
71
|
+
// Map the normalized match span back onto the raw text. `map[end]` points at the
|
|
72
|
+
// start of the character *after* the match, which — when that character is a
|
|
73
|
+
// collapsed space — lies past a run of raw whitespace; trim it so the matched
|
|
74
|
+
// text ends on real content. The anchor still starts at `rawStart`.
|
|
75
|
+
const rawStart = map[at]!;
|
|
76
|
+
const rawEnd = trimEnd(rawText, rawStart, map[end]!);
|
|
77
|
+
const matched = rawText.slice(rawStart, rawEnd);
|
|
78
|
+
|
|
79
|
+
// Context window in normalized space, then mapped back and word-trimmed on the
|
|
80
|
+
// raw text so the shown context carries no partial word at either edge.
|
|
81
|
+
const ctxNormStart = Math.max(0, at - radius);
|
|
82
|
+
const ctxNormEnd = Math.min(haystack.length, end + radius);
|
|
83
|
+
const rawCtxStart = map[ctxNormStart]!;
|
|
84
|
+
const rawCtxEnd = map[ctxNormEnd]!;
|
|
85
|
+
const context = trimToWords(
|
|
86
|
+
rawText,
|
|
87
|
+
rawCtxStart,
|
|
88
|
+
rawCtxEnd,
|
|
89
|
+
rawStart,
|
|
90
|
+
rawEnd,
|
|
91
|
+
options.locale,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
yield {
|
|
95
|
+
text: matched,
|
|
96
|
+
context,
|
|
97
|
+
position: capturePosition(rawText, rawStart, sectionId),
|
|
98
|
+
sectionIndex,
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// Advance past this match; guard against a zero-length needle (impossible here,
|
|
102
|
+
// an empty needle returned above) so the loop always progresses.
|
|
103
|
+
from = end > at ? end : at + 1;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Trims a raw context window `[start, end)` inward to whole-word boundaries via
|
|
109
|
+
* `Intl.Segmenter`, never cutting into the match span `[matchStart, matchEnd)`. A
|
|
110
|
+
* word straddling the left edge is dropped forward to its end (the next word's
|
|
111
|
+
* start); a word straddling the right edge is dropped back to its start. The result
|
|
112
|
+
* is whitespace-collapsed for a compact one-line context.
|
|
113
|
+
*/
|
|
114
|
+
function trimToWords(
|
|
115
|
+
text: string,
|
|
116
|
+
start: number,
|
|
117
|
+
end: number,
|
|
118
|
+
matchStart: number,
|
|
119
|
+
matchEnd: number,
|
|
120
|
+
locale: string | undefined,
|
|
121
|
+
): string {
|
|
122
|
+
// Segment a slice wider than the window so a word straddling an edge is visible
|
|
123
|
+
// whole. Offsets are relative to the wide slice, so add `lo`.
|
|
124
|
+
const lo = Math.max(0, Math.min(start, matchStart) - 24);
|
|
125
|
+
const hi = Math.min(text.length, Math.max(end, matchEnd) + 24);
|
|
126
|
+
let head = start;
|
|
127
|
+
let tail = end;
|
|
128
|
+
for (const seg of wordSegmenter(locale).segment(text.slice(lo, hi))) {
|
|
129
|
+
if (!seg.isWordLike) continue;
|
|
130
|
+
const segStart = lo + seg.index;
|
|
131
|
+
const segEnd = segStart + seg.segment.length;
|
|
132
|
+
// A word-like segment straddling the left edge: move head to its end (the next
|
|
133
|
+
// word's start), but never past the match start.
|
|
134
|
+
if (segStart < start && segEnd > start) head = Math.min(matchStart, segEnd);
|
|
135
|
+
// A word-like segment straddling the right edge: move tail to its start, but
|
|
136
|
+
// never before the match end.
|
|
137
|
+
if (segStart < end && segEnd > end) tail = Math.max(matchEnd, segStart);
|
|
138
|
+
}
|
|
139
|
+
return collapse(text.slice(head, tail));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Pull `end` back past any trailing whitespace, never before `start`. */
|
|
143
|
+
function trimEnd(text: string, start: number, end: number): number {
|
|
144
|
+
let at = end;
|
|
145
|
+
while (at > start && /\s/.test(text[at - 1]!)) at -= 1;
|
|
146
|
+
return at;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let cachedWordSegmenter: { locale: string | undefined; segmenter: Intl.Segmenter } | undefined;
|
|
150
|
+
|
|
151
|
+
function wordSegmenter(locale: string | undefined): Intl.Segmenter {
|
|
152
|
+
if (cachedWordSegmenter === undefined || cachedWordSegmenter.locale !== locale) {
|
|
153
|
+
cachedWordSegmenter = { locale, segmenter: new Intl.Segmenter(locale, { granularity: 'word' }) };
|
|
154
|
+
}
|
|
155
|
+
return cachedWordSegmenter.segmenter;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Lazily scans every section of `book` for `query`, one section at a time, yielding
|
|
160
|
+
* each hit as it is found. The book is never buffered whole: each section's bytes are
|
|
161
|
+
* loaded, extracted, matched and released before the next is touched. A consumer that
|
|
162
|
+
* stops early (`break`/`return`) halts the scan — no later section is loaded.
|
|
163
|
+
*
|
|
164
|
+
* `onSection` (test-only instrumentation seam) fires with each section's index the
|
|
165
|
+
* moment before it is decoded, so a test can prove laziness by counting decodes.
|
|
166
|
+
*/
|
|
167
|
+
export async function* searchBook(
|
|
168
|
+
book: Book,
|
|
169
|
+
query: string,
|
|
170
|
+
options: SearchOptions & { onSection?: (index: number) => void } = {},
|
|
171
|
+
): AsyncGenerator<SearchHit> {
|
|
172
|
+
const needle = normalizeQuery(query);
|
|
173
|
+
if (needle === '') return;
|
|
174
|
+
|
|
175
|
+
const sections = book.sections;
|
|
176
|
+
for (let index = 0; index < sections.length; index += 1) {
|
|
177
|
+
const section: Section = sections[index]!;
|
|
178
|
+
options.onSection?.(index);
|
|
179
|
+
const bytes = await section.load();
|
|
180
|
+
// The section's own resolver rides along so extraction shows an <img> the
|
|
181
|
+
// way the frame will: rendered (no text) or substituted by its alt.
|
|
182
|
+
const rawText = extractSectionText(bytes, section.resolve?.bind(section));
|
|
183
|
+
for (const hit of matchText(rawText, query, section.id, index, options)) {
|
|
184
|
+
yield hit;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search normalization — the folding policy that decides when two strings "match"
|
|
3
|
+
* for the purpose of full-text search. Applied to both the query and the extracted
|
|
4
|
+
* section text before literal substring matching.
|
|
5
|
+
*
|
|
6
|
+
* Policy (see docs/domains/search.md ## Key decisions):
|
|
7
|
+
* - **Case-insensitive.** Folded with `toLowerCase()`.
|
|
8
|
+
* - **Unicode NFC.** Each grapheme is canonically composed, so a pre-composed `é`
|
|
9
|
+
* and a decomposed `e`+combining-acute match.
|
|
10
|
+
* - **Diacritics are preserved.** `café` does not match `cafe`. Stripping marks is a
|
|
11
|
+
* locale-sensitive judgement (German ä, Turkish dotless ı) better left to a later,
|
|
12
|
+
* opt-in dimension than baked into the default.
|
|
13
|
+
* - **Smart punctuation folded to ASCII.** Curly quotes → straight, en/em dashes and
|
|
14
|
+
* the minus sign → hyphen-minus, non-breaking and other Unicode spaces → a normal
|
|
15
|
+
* space. A reader types `don't`, the book prints `don’t`.
|
|
16
|
+
* - **Whitespace collapsed.** Every run of whitespace (including the newlines that
|
|
17
|
+
* land between block elements during extraction) becomes a single space, and
|
|
18
|
+
* leading/trailing space folds off, so a query never fails on invisible layout
|
|
19
|
+
* whitespace.
|
|
20
|
+
*
|
|
21
|
+
* The fold must stay anchorable: the matcher works on the normalized string but each
|
|
22
|
+
* hit's text and `Position` anchor into the **raw extracted** text. So the fold is
|
|
23
|
+
* built grapheme by grapheme alongside an offset map from every normalized index
|
|
24
|
+
* back to the raw UTF-16 offset it came from. The raw text handed to
|
|
25
|
+
* `capturePosition` is never NFC-rewritten (it must stay byte-identical to what the
|
|
26
|
+
* paginator measures against), so composition happens only inside the folded copy.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** A normalized string plus the map from its indices back to raw UTF-16 offsets. */
|
|
30
|
+
export interface NormalizedText {
|
|
31
|
+
/** The folded, whitespace-collapsed text the matcher searches. */
|
|
32
|
+
readonly text: string;
|
|
33
|
+
/**
|
|
34
|
+
* `map[i]` is the raw UTF-16 offset of the character that produced normalized
|
|
35
|
+
* code unit `i`; `map[text.length]` is the raw text length. Monotonic
|
|
36
|
+
* non-decreasing, so a normalized span `[a, b)` maps to the raw span
|
|
37
|
+
* `[map[a], map[b])`.
|
|
38
|
+
*/
|
|
39
|
+
readonly map: readonly number[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const SMART: ReadonlyMap<string, string> = new Map([
|
|
43
|
+
['‘', "'"], // ‘ left single quote
|
|
44
|
+
['’', "'"], // ’ right single quote / apostrophe
|
|
45
|
+
['‚', "'"], // ‚ single low-9 quote
|
|
46
|
+
['‛', "'"], // ‛ single high-reversed-9 quote
|
|
47
|
+
['“', '"'], // “ left double quote
|
|
48
|
+
['”', '"'], // ” right double quote
|
|
49
|
+
['„', '"'], // „ double low-9 quote
|
|
50
|
+
['‟', '"'], // ‟ double high-reversed-9 quote
|
|
51
|
+
['′', "'"], // ′ prime
|
|
52
|
+
['″', '"'], // ″ double prime
|
|
53
|
+
['‐', '-'], // ‐ hyphen
|
|
54
|
+
['‑', '-'], // ‑ non-breaking hyphen
|
|
55
|
+
['‒', '-'], // ‒ figure dash
|
|
56
|
+
['–', '-'], // – en dash
|
|
57
|
+
['—', '-'], // — em dash
|
|
58
|
+
['―', '-'], // ― horizontal bar
|
|
59
|
+
['−', '-'], // − minus sign
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
const WHITESPACE = /^\s$/;
|
|
63
|
+
|
|
64
|
+
let graphemeSegmenter: Intl.Segmenter | undefined;
|
|
65
|
+
|
|
66
|
+
function segmenter(): Intl.Segmenter {
|
|
67
|
+
graphemeSegmenter ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' });
|
|
68
|
+
return graphemeSegmenter;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** True when every code point in a grapheme is whitespace. */
|
|
72
|
+
function isWhitespace(grapheme: string): boolean {
|
|
73
|
+
for (const ch of grapheme) {
|
|
74
|
+
if (!WHITESPACE.test(ch) && ch !== '' && ch !== '') return false;
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Folds a single grapheme under the punctuation/NFC/case policy. */
|
|
80
|
+
function foldGrapheme(grapheme: string): string {
|
|
81
|
+
const mapped = grapheme.length === 1 ? (SMART.get(grapheme) ?? grapheme) : grapheme;
|
|
82
|
+
return mapped.normalize('NFC').toLowerCase();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Folds `raw` under the search policy and returns the normalized text plus the
|
|
87
|
+
* index map back to raw UTF-16 offsets. A match found in `text` maps cleanly onto a
|
|
88
|
+
* raw span for `capturePosition`.
|
|
89
|
+
*/
|
|
90
|
+
export function normalizeText(raw: string): NormalizedText {
|
|
91
|
+
let text = '';
|
|
92
|
+
const map: number[] = [];
|
|
93
|
+
let pendingSpace = false;
|
|
94
|
+
let sawNonSpace = false;
|
|
95
|
+
let rawOffset = 0;
|
|
96
|
+
|
|
97
|
+
for (const { segment } of segmenter().segment(raw)) {
|
|
98
|
+
if (isWhitespace(segment)) {
|
|
99
|
+
if (sawNonSpace) pendingSpace = true;
|
|
100
|
+
rawOffset += segment.length;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (pendingSpace) {
|
|
104
|
+
map.push(rawOffset); // the collapsed space anchors at the start of the text it precedes
|
|
105
|
+
text += ' ';
|
|
106
|
+
pendingSpace = false;
|
|
107
|
+
}
|
|
108
|
+
const folded = foldGrapheme(segment);
|
|
109
|
+
for (let unit = 0; unit < folded.length; unit += 1) map.push(rawOffset);
|
|
110
|
+
text += folded;
|
|
111
|
+
sawNonSpace = true;
|
|
112
|
+
rawOffset += segment.length;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
map.push(raw.length); // trailing whitespace folds off; the terminal maps to raw end
|
|
116
|
+
return { text, map };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The query folded to the same space as {@link normalizeText}'s `text`. */
|
|
120
|
+
export function normalizeQuery(query: string): string {
|
|
121
|
+
return normalizeText(query).text;
|
|
122
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What is allowed to survive sanitization, stated as allowlists.
|
|
3
|
+
*
|
|
4
|
+
* A blocklist loses to the next vector the platform invents; an allowlist only
|
|
5
|
+
* ever loses formatting. Anything absent from these tables is either unwrapped
|
|
6
|
+
* (the element goes, its text stays — a book is text before it is markup) or,
|
|
7
|
+
* when its children are not readable content, discarded whole.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
function set(...names: string[]): ReadonlySet<string> {
|
|
11
|
+
return new Set(names);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const NONE: ReadonlySet<string> = new Set<string>();
|
|
15
|
+
|
|
16
|
+
export const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml';
|
|
17
|
+
export const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
|
|
18
|
+
export const XLINK_NAMESPACE = 'http://www.w3.org/1999/xlink';
|
|
19
|
+
|
|
20
|
+
export const HTML_GLOBAL_ATTRIBUTES = set(
|
|
21
|
+
'class',
|
|
22
|
+
'dir',
|
|
23
|
+
'epub:type',
|
|
24
|
+
'hidden',
|
|
25
|
+
'id',
|
|
26
|
+
'lang',
|
|
27
|
+
'role',
|
|
28
|
+
'style',
|
|
29
|
+
'title',
|
|
30
|
+
'translate',
|
|
31
|
+
'xml:lang',
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const CELL = set('abbr', 'align', 'colspan', 'headers', 'rowspan', 'scope', 'valign', 'width');
|
|
35
|
+
|
|
36
|
+
export const HTML_ELEMENTS: ReadonlyMap<string, ReadonlySet<string>> = new Map([
|
|
37
|
+
['a', set('href')],
|
|
38
|
+
['abbr', NONE],
|
|
39
|
+
['address', NONE],
|
|
40
|
+
['article', NONE],
|
|
41
|
+
['aside', NONE],
|
|
42
|
+
['b', NONE],
|
|
43
|
+
['bdi', NONE],
|
|
44
|
+
['bdo', NONE],
|
|
45
|
+
['big', NONE],
|
|
46
|
+
['blockquote', set('cite')],
|
|
47
|
+
['br', NONE],
|
|
48
|
+
['caption', set('align')],
|
|
49
|
+
['center', NONE],
|
|
50
|
+
['cite', NONE],
|
|
51
|
+
['code', NONE],
|
|
52
|
+
['col', set('align', 'span', 'valign', 'width')],
|
|
53
|
+
['colgroup', set('align', 'span', 'valign', 'width')],
|
|
54
|
+
['dd', NONE],
|
|
55
|
+
['del', set('cite', 'datetime')],
|
|
56
|
+
['details', set('open')],
|
|
57
|
+
['dfn', NONE],
|
|
58
|
+
['div', NONE],
|
|
59
|
+
['dl', NONE],
|
|
60
|
+
['dt', NONE],
|
|
61
|
+
['em', NONE],
|
|
62
|
+
['figcaption', NONE],
|
|
63
|
+
['figure', NONE],
|
|
64
|
+
['footer', NONE],
|
|
65
|
+
['h1', NONE],
|
|
66
|
+
['h2', NONE],
|
|
67
|
+
['h3', NONE],
|
|
68
|
+
['h4', NONE],
|
|
69
|
+
['h5', NONE],
|
|
70
|
+
['h6', NONE],
|
|
71
|
+
['header', NONE],
|
|
72
|
+
['hgroup', NONE],
|
|
73
|
+
['hr', NONE],
|
|
74
|
+
['i', NONE],
|
|
75
|
+
['img', set('alt', 'height', 'src', 'width')],
|
|
76
|
+
['ins', set('cite', 'datetime')],
|
|
77
|
+
['kbd', NONE],
|
|
78
|
+
['li', set('value')],
|
|
79
|
+
['link', set('href', 'media', 'rel', 'type')],
|
|
80
|
+
['main', NONE],
|
|
81
|
+
['mark', NONE],
|
|
82
|
+
['nav', NONE],
|
|
83
|
+
['ol', set('reversed', 'start', 'type')],
|
|
84
|
+
['p', NONE],
|
|
85
|
+
['pre', NONE],
|
|
86
|
+
['q', set('cite')],
|
|
87
|
+
['rp', NONE],
|
|
88
|
+
['rt', NONE],
|
|
89
|
+
['ruby', NONE],
|
|
90
|
+
['s', NONE],
|
|
91
|
+
['samp', NONE],
|
|
92
|
+
['section', NONE],
|
|
93
|
+
['small', NONE],
|
|
94
|
+
['span', NONE],
|
|
95
|
+
['strike', NONE],
|
|
96
|
+
['strong', NONE],
|
|
97
|
+
['style', set('media', 'type')],
|
|
98
|
+
['sub', NONE],
|
|
99
|
+
['summary', NONE],
|
|
100
|
+
['sup', NONE],
|
|
101
|
+
['table', set('align', 'border', 'cellpadding', 'cellspacing', 'summary', 'width')],
|
|
102
|
+
['tbody', set('align', 'valign')],
|
|
103
|
+
['td', CELL],
|
|
104
|
+
['tfoot', set('align', 'valign')],
|
|
105
|
+
['th', CELL],
|
|
106
|
+
['thead', set('align', 'valign')],
|
|
107
|
+
['time', set('datetime')],
|
|
108
|
+
['tr', set('align', 'valign')],
|
|
109
|
+
['tt', NONE],
|
|
110
|
+
['u', NONE],
|
|
111
|
+
['ul', NONE],
|
|
112
|
+
['var', NONE],
|
|
113
|
+
['wbr', NONE],
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Elements that go with everything inside them, because their children are not
|
|
118
|
+
* prose: raw-text elements whose content is source rather than text, form
|
|
119
|
+
* controls, and void elements with no children to keep.
|
|
120
|
+
*
|
|
121
|
+
* Everything else absent from the allowlist is *unwrapped* instead — the
|
|
122
|
+
* element goes, its text stays. That distinction is load-bearing twice over. A
|
|
123
|
+
* book is text before it is markup, and a malformed one nests half a chapter
|
|
124
|
+
* inside a stray element: an </noscript> end tag after an open <p> is ignored
|
|
125
|
+
* by the HTML parser, so an unclosed <noscript> swallows everything after it,
|
|
126
|
+
* and discarding that element would delete the rest of the chapter.
|
|
127
|
+
*
|
|
128
|
+
* <noscript> is also why unwrapping is the safer option and not merely the
|
|
129
|
+
* kinder one. Its content is markup when a document is parsed with scripting
|
|
130
|
+
* off and raw text when it is parsed with scripting on, so an element that
|
|
131
|
+
* survives into the frame is re-read in the other mode and attribute
|
|
132
|
+
* boundaries move under it — the mutation-XSS shape this pipeline exists to
|
|
133
|
+
* defeat. Removing the element removes the reinterpretation.
|
|
134
|
+
*
|
|
135
|
+
* The table itself lives in `src/core/reading-text.ts` (re-exported here under
|
|
136
|
+
* its frozen name): discarding decides not only what renders but what the
|
|
137
|
+
* frame's *text* is, and the headless search extractor must mirror it exactly
|
|
138
|
+
* so a hit's anchor exists in the text the frame resolves against. One table,
|
|
139
|
+
* two consumers — never fork it.
|
|
140
|
+
*/
|
|
141
|
+
export { DISCARDED_HTML_ELEMENTS as HTML_DISCARDED } from '../core/reading-text.ts';
|
|
142
|
+
|
|
143
|
+
export const SVG_GLOBAL_ATTRIBUTES = set(
|
|
144
|
+
'class',
|
|
145
|
+
'clip-path',
|
|
146
|
+
'clip-rule',
|
|
147
|
+
'color',
|
|
148
|
+
'display',
|
|
149
|
+
'dominant-baseline',
|
|
150
|
+
'fill',
|
|
151
|
+
'fill-opacity',
|
|
152
|
+
'fill-rule',
|
|
153
|
+
'font-family',
|
|
154
|
+
'font-size',
|
|
155
|
+
'font-style',
|
|
156
|
+
'font-weight',
|
|
157
|
+
'id',
|
|
158
|
+
'lang',
|
|
159
|
+
'letter-spacing',
|
|
160
|
+
'mask',
|
|
161
|
+
'opacity',
|
|
162
|
+
'overflow',
|
|
163
|
+
'paint-order',
|
|
164
|
+
'role',
|
|
165
|
+
'shape-rendering',
|
|
166
|
+
'space',
|
|
167
|
+
'stop-color',
|
|
168
|
+
'stop-opacity',
|
|
169
|
+
'stroke',
|
|
170
|
+
'stroke-dasharray',
|
|
171
|
+
'stroke-dashoffset',
|
|
172
|
+
'stroke-linecap',
|
|
173
|
+
'stroke-linejoin',
|
|
174
|
+
'stroke-opacity',
|
|
175
|
+
'stroke-width',
|
|
176
|
+
'style',
|
|
177
|
+
'text-anchor',
|
|
178
|
+
'transform',
|
|
179
|
+
'vector-effect',
|
|
180
|
+
'visibility',
|
|
181
|
+
'word-spacing',
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const GRADIENT = set(
|
|
185
|
+
'cx',
|
|
186
|
+
'cy',
|
|
187
|
+
'fr',
|
|
188
|
+
'fx',
|
|
189
|
+
'fy',
|
|
190
|
+
'gradientTransform',
|
|
191
|
+
'gradientUnits',
|
|
192
|
+
'r',
|
|
193
|
+
'spreadMethod',
|
|
194
|
+
'x1',
|
|
195
|
+
'x2',
|
|
196
|
+
'y1',
|
|
197
|
+
'y2',
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
const TEXT_POSITION = set('dx', 'dy', 'rotate', 'textLength', 'x', 'y');
|
|
201
|
+
|
|
202
|
+
export const SVG_ELEMENTS: ReadonlyMap<string, ReadonlySet<string>> = new Map([
|
|
203
|
+
['a', set('href')],
|
|
204
|
+
['circle', set('cx', 'cy', 'r')],
|
|
205
|
+
['clipPath', set('clipPathUnits')],
|
|
206
|
+
['defs', NONE],
|
|
207
|
+
['desc', NONE],
|
|
208
|
+
['ellipse', set('cx', 'cy', 'rx', 'ry')],
|
|
209
|
+
['g', NONE],
|
|
210
|
+
['image', set('height', 'href', 'preserveAspectRatio', 'width', 'x', 'y')],
|
|
211
|
+
['line', set('x1', 'x2', 'y1', 'y2')],
|
|
212
|
+
['linearGradient', GRADIENT],
|
|
213
|
+
['marker', set('markerHeight', 'markerUnits', 'markerWidth', 'orient', 'refX', 'refY', 'viewBox')],
|
|
214
|
+
['mask', set('height', 'maskContentUnits', 'maskUnits', 'width', 'x', 'y')],
|
|
215
|
+
['path', set('d', 'pathLength')],
|
|
216
|
+
['pattern', set('height', 'patternContentUnits', 'patternTransform', 'patternUnits', 'viewBox', 'width', 'x', 'y')],
|
|
217
|
+
['polygon', set('points')],
|
|
218
|
+
['polyline', set('points')],
|
|
219
|
+
['radialGradient', GRADIENT],
|
|
220
|
+
['rect', set('height', 'rx', 'ry', 'width', 'x', 'y')],
|
|
221
|
+
['stop', set('offset')],
|
|
222
|
+
['style', set('media', 'type')],
|
|
223
|
+
['svg', set('height', 'preserveAspectRatio', 'version', 'viewBox', 'width', 'x', 'y')],
|
|
224
|
+
['symbol', set('preserveAspectRatio', 'viewBox')],
|
|
225
|
+
['text', TEXT_POSITION],
|
|
226
|
+
['title', NONE],
|
|
227
|
+
['tspan', TEXT_POSITION],
|
|
228
|
+
['use', set('height', 'href', 'width', 'x', 'y')],
|
|
229
|
+
]);
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* SMIL animation is on this list, not merely absent from the allowlist, because
|
|
233
|
+
* `<animate attributeName="href" to="javascript:...">` turns an inert element
|
|
234
|
+
* into a live one after sanitization has already run.
|
|
235
|
+
*
|
|
236
|
+
* Shared from `src/core/reading-text.ts` for the same reason as HTML_DISCARDED:
|
|
237
|
+
* the search extractor mirrors what discarding removes from the frame's text.
|
|
238
|
+
*/
|
|
239
|
+
export { DISCARDED_SVG_ELEMENTS as SVG_DISCARDED } from '../core/reading-text.ts';
|
|
240
|
+
|
|
241
|
+
/** Attributes carrying a reference, by element local name. */
|
|
242
|
+
export const URL_ATTRIBUTES: ReadonlyMap<string, ReadonlySet<string>> = new Map([
|
|
243
|
+
['a', set('href')],
|
|
244
|
+
['blockquote', set('cite')],
|
|
245
|
+
['del', set('cite')],
|
|
246
|
+
['image', set('href')],
|
|
247
|
+
['img', set('src')],
|
|
248
|
+
['ins', set('cite')],
|
|
249
|
+
['link', set('href')],
|
|
250
|
+
['q', set('cite')],
|
|
251
|
+
['use', set('href')],
|
|
252
|
+
]);
|
|
253
|
+
|
|
254
|
+
const LINK_SCHEMES = set('http', 'https', 'mailto', 'tel');
|
|
255
|
+
const MEDIA_SCHEMES = set('data', 'http', 'https');
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Whether a reference may stay on the attribute at all. Relative references are
|
|
259
|
+
* kept so they can be resolved to blob URLs; remote ones are kept so CSP is the
|
|
260
|
+
* layer that refuses them, and refuses them observably.
|
|
261
|
+
*/
|
|
262
|
+
export function isAllowedReference(element: string, attribute: string, kind: string, scheme: string | undefined): boolean {
|
|
263
|
+
// <use href> reaches into the same document and nowhere else: a data: URI
|
|
264
|
+
// there is a document-injection vector, not an image reference.
|
|
265
|
+
if (element === 'use') return kind === 'fragment';
|
|
266
|
+
if (kind === 'fragment') return element === 'a';
|
|
267
|
+
if (kind === 'relative') return true;
|
|
268
|
+
if (kind !== 'scheme' || scheme === undefined) return false;
|
|
269
|
+
if (attribute === 'src' || (element === 'image' && attribute === 'href') || element === 'link') {
|
|
270
|
+
return MEDIA_SCHEMES.has(scheme);
|
|
271
|
+
}
|
|
272
|
+
return LINK_SCHEMES.has(scheme);
|
|
273
|
+
}
|