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.
Files changed (233) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +277 -0
  3. package/dist/core/book.d.ts +75 -0
  4. package/dist/core/book.d.ts.map +1 -0
  5. package/dist/core/book.js +2 -0
  6. package/dist/core/book.js.map +1 -0
  7. package/dist/core/errors.d.ts +17 -0
  8. package/dist/core/errors.d.ts.map +1 -0
  9. package/dist/core/errors.js +29 -0
  10. package/dist/core/errors.js.map +1 -0
  11. package/dist/core/format.d.ts +13 -0
  12. package/dist/core/format.d.ts.map +1 -0
  13. package/dist/core/format.js +2 -0
  14. package/dist/core/format.js.map +1 -0
  15. package/dist/core/graphemes.d.ts +36 -0
  16. package/dist/core/graphemes.d.ts.map +1 -0
  17. package/dist/core/graphemes.js +62 -0
  18. package/dist/core/graphemes.js.map +1 -0
  19. package/dist/core/index.d.ts +11 -0
  20. package/dist/core/index.d.ts.map +1 -0
  21. package/dist/core/index.js +6 -0
  22. package/dist/core/index.js.map +1 -0
  23. package/dist/core/lookup.d.ts +10 -0
  24. package/dist/core/lookup.d.ts.map +1 -0
  25. package/dist/core/lookup.js +16 -0
  26. package/dist/core/lookup.js.map +1 -0
  27. package/dist/core/open.d.ts +12 -0
  28. package/dist/core/open.d.ts.map +1 -0
  29. package/dist/core/open.js +23 -0
  30. package/dist/core/open.js.map +1 -0
  31. package/dist/core/position.d.ts +121 -0
  32. package/dist/core/position.d.ts.map +1 -0
  33. package/dist/core/position.js +327 -0
  34. package/dist/core/position.js.map +1 -0
  35. package/dist/core/reading-text.d.ts +77 -0
  36. package/dist/core/reading-text.d.ts.map +1 -0
  37. package/dist/core/reading-text.js +144 -0
  38. package/dist/core/reading-text.js.map +1 -0
  39. package/dist/core/source.d.ts +17 -0
  40. package/dist/core/source.d.ts.map +1 -0
  41. package/dist/core/source.js +39 -0
  42. package/dist/core/source.js.map +1 -0
  43. package/dist/core/storage.d.ts +7 -0
  44. package/dist/core/storage.d.ts.map +1 -0
  45. package/dist/core/storage.js +2 -0
  46. package/dist/core/storage.js.map +1 -0
  47. package/dist/core/text.d.ts +18 -0
  48. package/dist/core/text.d.ts.map +1 -0
  49. package/dist/core/text.js +32 -0
  50. package/dist/core/text.js.map +1 -0
  51. package/dist/formats/epub/href.d.ts +14 -0
  52. package/dist/formats/epub/href.d.ts.map +1 -0
  53. package/dist/formats/epub/href.js +52 -0
  54. package/dist/formats/epub/href.js.map +1 -0
  55. package/dist/formats/epub/index.d.ts +3 -0
  56. package/dist/formats/epub/index.d.ts.map +1 -0
  57. package/dist/formats/epub/index.js +223 -0
  58. package/dist/formats/epub/index.js.map +1 -0
  59. package/dist/formats/epub/opf.d.ts +27 -0
  60. package/dist/formats/epub/opf.d.ts.map +1 -0
  61. package/dist/formats/epub/opf.js +110 -0
  62. package/dist/formats/epub/opf.js.map +1 -0
  63. package/dist/formats/epub/toc.d.ts +6 -0
  64. package/dist/formats/epub/toc.d.ts.map +1 -0
  65. package/dist/formats/epub/toc.js +88 -0
  66. package/dist/formats/epub/toc.js.map +1 -0
  67. package/dist/formats/fb2/index.d.ts +13 -0
  68. package/dist/formats/fb2/index.d.ts.map +1 -0
  69. package/dist/formats/fb2/index.js +317 -0
  70. package/dist/formats/fb2/index.js.map +1 -0
  71. package/dist/formats/index.d.ts +4 -0
  72. package/dist/formats/index.d.ts.map +1 -0
  73. package/dist/formats/index.js +4 -0
  74. package/dist/formats/index.js.map +1 -0
  75. package/dist/formats/text/index.d.ts +11 -0
  76. package/dist/formats/text/index.d.ts.map +1 -0
  77. package/dist/formats/text/index.js +201 -0
  78. package/dist/formats/text/index.js.map +1 -0
  79. package/dist/formats/xml.d.ts +33 -0
  80. package/dist/formats/xml.d.ts.map +1 -0
  81. package/dist/formats/xml.js +321 -0
  82. package/dist/formats/xml.js.map +1 -0
  83. package/dist/index.d.ts +27 -0
  84. package/dist/index.d.ts.map +1 -0
  85. package/dist/index.js +28 -0
  86. package/dist/index.js.map +1 -0
  87. package/dist/layout/chunk.d.ts +51 -0
  88. package/dist/layout/chunk.d.ts.map +1 -0
  89. package/dist/layout/chunk.js +131 -0
  90. package/dist/layout/chunk.js.map +1 -0
  91. package/dist/layout/index.d.ts +238 -0
  92. package/dist/layout/index.d.ts.map +1 -0
  93. package/dist/layout/index.js +476 -0
  94. package/dist/layout/index.js.map +1 -0
  95. package/dist/reader/index.d.ts +231 -0
  96. package/dist/reader/index.d.ts.map +1 -0
  97. package/dist/reader/index.js +958 -0
  98. package/dist/reader/index.js.map +1 -0
  99. package/dist/reader/input.d.ts +69 -0
  100. package/dist/reader/input.d.ts.map +1 -0
  101. package/dist/reader/input.js +75 -0
  102. package/dist/reader/input.js.map +1 -0
  103. package/dist/search/extract.d.ts +51 -0
  104. package/dist/search/extract.d.ts.map +1 -0
  105. package/dist/search/extract.js +401 -0
  106. package/dist/search/extract.js.map +1 -0
  107. package/dist/search/index.d.ts +11 -0
  108. package/dist/search/index.d.ts.map +1 -0
  109. package/dist/search/index.js +11 -0
  110. package/dist/search/index.js.map +1 -0
  111. package/dist/search/matcher.d.ts +56 -0
  112. package/dist/search/matcher.d.ts.map +1 -0
  113. package/dist/search/matcher.js +128 -0
  114. package/dist/search/matcher.js.map +1 -0
  115. package/dist/search/normalize.d.ts +48 -0
  116. package/dist/search/normalize.d.ts.map +1 -0
  117. package/dist/search/normalize.js +103 -0
  118. package/dist/search/normalize.js.map +1 -0
  119. package/dist/view/allowlist.d.ts +59 -0
  120. package/dist/view/allowlist.d.ts.map +1 -0
  121. package/dist/view/allowlist.js +198 -0
  122. package/dist/view/allowlist.js.map +1 -0
  123. package/dist/view/appearance.d.ts +137 -0
  124. package/dist/view/appearance.d.ts.map +1 -0
  125. package/dist/view/appearance.js +274 -0
  126. package/dist/view/appearance.js.map +1 -0
  127. package/dist/view/css.d.ts +24 -0
  128. package/dist/view/css.d.ts.map +1 -0
  129. package/dist/view/css.js +153 -0
  130. package/dist/view/css.js.map +1 -0
  131. package/dist/view/frame.d.ts +54 -0
  132. package/dist/view/frame.d.ts.map +1 -0
  133. package/dist/view/frame.js +980 -0
  134. package/dist/view/frame.js.map +1 -0
  135. package/dist/view/host.d.ts +160 -0
  136. package/dist/view/host.d.ts.map +1 -0
  137. package/dist/view/host.js +385 -0
  138. package/dist/view/host.js.map +1 -0
  139. package/dist/view/index.d.ts +12 -0
  140. package/dist/view/index.d.ts.map +1 -0
  141. package/dist/view/index.js +7 -0
  142. package/dist/view/index.js.map +1 -0
  143. package/dist/view/protocol.d.ts +220 -0
  144. package/dist/view/protocol.d.ts.map +1 -0
  145. package/dist/view/protocol.js +246 -0
  146. package/dist/view/protocol.js.map +1 -0
  147. package/dist/view/reference.d.ts +18 -0
  148. package/dist/view/reference.d.ts.map +1 -0
  149. package/dist/view/reference.js +25 -0
  150. package/dist/view/reference.js.map +1 -0
  151. package/dist/view/resources.d.ts +51 -0
  152. package/dist/view/resources.d.ts.map +1 -0
  153. package/dist/view/resources.js +242 -0
  154. package/dist/view/resources.js.map +1 -0
  155. package/dist/view/sanitize.d.ts +32 -0
  156. package/dist/view/sanitize.d.ts.map +1 -0
  157. package/dist/view/sanitize.js +226 -0
  158. package/dist/view/sanitize.js.map +1 -0
  159. package/dist/zip/central.d.ts +12 -0
  160. package/dist/zip/central.d.ts.map +1 -0
  161. package/dist/zip/central.js +72 -0
  162. package/dist/zip/central.js.map +1 -0
  163. package/dist/zip/crc32.d.ts +3 -0
  164. package/dist/zip/crc32.d.ts.map +1 -0
  165. package/dist/zip/crc32.js +28 -0
  166. package/dist/zip/crc32.js.map +1 -0
  167. package/dist/zip/eocd.d.ts +9 -0
  168. package/dist/zip/eocd.d.ts.map +1 -0
  169. package/dist/zip/eocd.js +84 -0
  170. package/dist/zip/eocd.js.map +1 -0
  171. package/dist/zip/errors.d.ts +19 -0
  172. package/dist/zip/errors.d.ts.map +1 -0
  173. package/dist/zip/errors.js +37 -0
  174. package/dist/zip/errors.js.map +1 -0
  175. package/dist/zip/index.d.ts +18 -0
  176. package/dist/zip/index.d.ts.map +1 -0
  177. package/dist/zip/index.js +78 -0
  178. package/dist/zip/index.js.map +1 -0
  179. package/dist/zip/inflate.d.ts +2 -0
  180. package/dist/zip/inflate.d.ts.map +1 -0
  181. package/dist/zip/inflate.js +43 -0
  182. package/dist/zip/inflate.js.map +1 -0
  183. package/dist/zip/source.d.ts +15 -0
  184. package/dist/zip/source.d.ts.map +1 -0
  185. package/dist/zip/source.js +34 -0
  186. package/dist/zip/source.js.map +1 -0
  187. package/package.json +75 -0
  188. package/src/core/book.ts +80 -0
  189. package/src/core/errors.ts +31 -0
  190. package/src/core/format.ts +14 -0
  191. package/src/core/graphemes.ts +73 -0
  192. package/src/core/index.ts +27 -0
  193. package/src/core/lookup.ts +16 -0
  194. package/src/core/open.ts +31 -0
  195. package/src/core/position.ts +485 -0
  196. package/src/core/reading-text.ts +200 -0
  197. package/src/core/source.ts +61 -0
  198. package/src/core/storage.ts +6 -0
  199. package/src/core/text.ts +35 -0
  200. package/src/formats/epub/href.ts +54 -0
  201. package/src/formats/epub/index.ts +235 -0
  202. package/src/formats/epub/opf.ts +143 -0
  203. package/src/formats/epub/toc.ts +120 -0
  204. package/src/formats/fb2/index.ts +348 -0
  205. package/src/formats/index.ts +3 -0
  206. package/src/formats/text/index.ts +208 -0
  207. package/src/formats/xml.ts +326 -0
  208. package/src/index.ts +26 -0
  209. package/src/layout/chunk.ts +169 -0
  210. package/src/layout/index.ts +573 -0
  211. package/src/reader/index.ts +1206 -0
  212. package/src/reader/input.ts +122 -0
  213. package/src/search/extract.ts +403 -0
  214. package/src/search/index.ts +10 -0
  215. package/src/search/matcher.ts +187 -0
  216. package/src/search/normalize.ts +122 -0
  217. package/src/view/allowlist.ts +273 -0
  218. package/src/view/appearance.ts +380 -0
  219. package/src/view/css.ts +172 -0
  220. package/src/view/frame.ts +1018 -0
  221. package/src/view/host.ts +527 -0
  222. package/src/view/index.ts +24 -0
  223. package/src/view/protocol.ts +412 -0
  224. package/src/view/reference.ts +30 -0
  225. package/src/view/resources.ts +265 -0
  226. package/src/view/sanitize.ts +266 -0
  227. package/src/zip/central.ts +85 -0
  228. package/src/zip/crc32.ts +30 -0
  229. package/src/zip/eocd.ts +97 -0
  230. package/src/zip/errors.ts +41 -0
  231. package/src/zip/index.ts +114 -0
  232. package/src/zip/inflate.ts +41 -0
  233. package/src/zip/source.ts +51 -0
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Input intent mapping for the reader facade.
3
+ *
4
+ * The sandboxed frame forwards *semantic* gestures — a nav-relevant keydown, a
5
+ * completed horizontal swipe, a non-link tap with its coords + the frame's
6
+ * viewport size (see `src/view/frame.ts`, `PROTOCOL_VERSION` 5). This module
7
+ * turns those into direction-aware navigation intents. It owns no DOM and no
8
+ * host vocabulary; it is pure geometry + a key table, so it is unit-checkable
9
+ * and the facade stays thin.
10
+ *
11
+ * Reading direction (`Book.direction`) flips the *horizontal* axis only:
12
+ * in RTL the visual right edge is the earlier page, so a rightward gesture goes
13
+ * *back* and a leftward gesture goes *forward*. Vertical arrows, PageUp/PageDown
14
+ * and Home/End are direction-neutral — PageDown always advances reading order,
15
+ * Home/End are always book start/end.
16
+ */
17
+ import type { ReadingDirection } from '../core/index.ts';
18
+
19
+ /** What an input event resolves to. `none` means the event maps to nothing. */
20
+ export type NavIntent = 'next' | 'prev' | 'nextSection' | 'prevSection' | 'start' | 'end' | 'none';
21
+
22
+ /**
23
+ * Tap-zone configuration. Each field is the fraction of the frame width [0..1]
24
+ * marking where a zone ends. `left` is the leftmost fraction that turns to the
25
+ * left-edge page; `right` is the fraction from which the right-edge page turns;
26
+ * the band between is inert. Directions are *visual* (left/right of the frame);
27
+ * RTL mapping is applied afterward. Defaults: left third / right third.
28
+ */
29
+ export interface TapZones {
30
+ /** Fraction [0..1]: taps with x < left*width hit the left zone. Default 1/3. */
31
+ readonly left?: number;
32
+ /** Fraction [0..1]: taps with x >= right*width hit the right zone. Default 2/3. */
33
+ readonly right?: number;
34
+ }
35
+
36
+ /** Per-reader input configuration. All optional; input is enabled by default. */
37
+ export interface InputConfig {
38
+ /** Keyboard navigation. Default true. */
39
+ readonly keyboard?: boolean;
40
+ /** Touch/pointer swipe navigation. Default true. */
41
+ readonly swipe?: boolean;
42
+ /** Tap-zone navigation, or its zone geometry. `false` disables tap zones. Default enabled. */
43
+ readonly tapZones?: TapZones | false;
44
+ }
45
+
46
+ const DEFAULT_LEFT = 1 / 3;
47
+ const DEFAULT_RIGHT = 2 / 3;
48
+ /** Below this |dx| a swipe is ignored (the frame already thresholds, this guards direction). */
49
+ const SWIPE_MIN = 1;
50
+
51
+ /** Turn a visual direction ('left'|'right') into a nav intent under `direction`. */
52
+ function edgeIntent(edge: 'left' | 'right', direction: ReadingDirection): NavIntent {
53
+ // LTR: right edge advances (next), left edge goes back (prev). RTL swaps.
54
+ const rtl = direction === 'rtl';
55
+ if (edge === 'right') return rtl ? 'prev' : 'next';
56
+ return rtl ? 'next' : 'prev';
57
+ }
58
+
59
+ /**
60
+ * Map a `KeyboardEvent.key` to a nav intent, direction-aware. Horizontal arrows
61
+ * flip under RTL; PageUp/PageDown, vertical arrows and Space are reading-order
62
+ * neutral (Space always advances, Shift+Space always retreats — the near-universal
63
+ * reader/browser convention); Home/End are always book start/end. The frame
64
+ * forwards Space as the normalized tokens `'Space'` / `'Shift+Space'` because
65
+ * `KeyboardEvent.key` is `' '` for both and the wire carries no modifier field;
66
+ * the raw `' '` spelling is accepted too for host-side callers.
67
+ */
68
+ export function keyIntent(key: string, direction: ReadingDirection): NavIntent {
69
+ switch (key) {
70
+ case 'ArrowRight':
71
+ return edgeIntent('right', direction);
72
+ case 'ArrowLeft':
73
+ return edgeIntent('left', direction);
74
+ case 'ArrowDown':
75
+ case 'PageDown':
76
+ case ' ':
77
+ case 'Space':
78
+ return 'next';
79
+ case 'ArrowUp':
80
+ case 'PageUp':
81
+ case 'Shift+ ':
82
+ case 'Shift+Space':
83
+ return 'prev';
84
+ case 'Home':
85
+ return 'start';
86
+ case 'End':
87
+ return 'end';
88
+ default:
89
+ return 'none';
90
+ }
91
+ }
92
+
93
+ /**
94
+ * Map a completed swipe's net delta to a nav intent. A leftward swipe (dx < 0)
95
+ * moves the page contents left → the next visual page; a rightward swipe (dx > 0)
96
+ * reveals the previous. RTL flips both. dy is accepted for symmetry with the
97
+ * wire shape but unused: the frame only forwards horizontal-dominant swipes.
98
+ */
99
+ export function swipeIntent(dx: number, _dy: number, direction: ReadingDirection): NavIntent {
100
+ if (Math.abs(dx) < SWIPE_MIN) return 'none';
101
+ return dx < 0 ? edgeIntent('right', direction) : edgeIntent('left', direction);
102
+ }
103
+
104
+ /**
105
+ * Map a tap to a nav intent using the zone fractions, direction-aware. The tap's
106
+ * x is normalized by the frame width; a tap in the left zone turns to the
107
+ * left-edge page and the right zone to the right-edge page (both flipped under
108
+ * RTL); the center band is inert.
109
+ */
110
+ export function tapIntent(
111
+ tap: { x: number; width: number },
112
+ zones: TapZones,
113
+ direction: ReadingDirection,
114
+ ): NavIntent {
115
+ if (tap.width <= 0) return 'none';
116
+ const fraction = tap.x / tap.width;
117
+ const left = zones.left ?? DEFAULT_LEFT;
118
+ const right = zones.right ?? DEFAULT_RIGHT;
119
+ if (fraction < left) return edgeIntent('left', direction);
120
+ if (fraction >= right) return edgeIntent('right', direction);
121
+ return 'none';
122
+ }
@@ -0,0 +1,403 @@
1
+ /**
2
+ * Per-section text extraction for search. Decodes a section's bytes and strips its
3
+ * markup to a single plain string — the **canonical reading text**, the same text
4
+ * the frame ultimately shows and measures, so a match's captured anchor (quote +
5
+ * context) exists verbatim in the text `goTo` resolves against.
6
+ *
7
+ * Headless by rule: this imports only `src/core` (the shared reading-text policy)
8
+ * and platform primitives (`TextDecoder`) and never touches `document`/`window`, so
9
+ * the whole search stack runs under `node:test`. It deliberately does **not** route
10
+ * through the frame's `Paginator.sectionText()` — that returns only the
11
+ * currently-paginated section and needs a browser. Search scans every section
12
+ * without painting it.
13
+ *
14
+ * Extraction is a small tag-stripping tokenizer rather than `DOMParser`: `DOMParser`
15
+ * is a browser primitive with no headless Node global, and search only needs text
16
+ * content. To line up with the frame it mirrors what the sanitize + resource
17
+ * pipeline does to a section's text, via the one shared policy in
18
+ * `src/core/reading-text.ts`:
19
+ *
20
+ * - Inline markup contributes no characters (`wo<em>rd</em>` is found as `word`) —
21
+ * `textContent` semantics, and unwrapped elements keep their text.
22
+ * - Elements the sanitizer **discards whole** (form controls, `template`, raw-text
23
+ * containers — `DISCARDED_HTML_ELEMENTS` / `DISCARDED_SVG_ELEMENTS`) contribute
24
+ * nothing, exactly as their text never reaches the frame.
25
+ * - An `<img>` contributes what the resource layer will show: nothing when it can
26
+ * be served (or is left for the CSP), its `alt` when the frame substitutes alt
27
+ * text (`imageReadingText`) — which is why extraction takes the section's
28
+ * `resolve` seam.
29
+ * - Whitespace-only text runs at the top level of the body are dropped, because
30
+ * the chunker drops those text nodes before the frame tiles the section text
31
+ * (`chunkElement` in `src/layout/chunk.ts`).
32
+ * - `head`, `script` and `style` content is never reading text (`<style>` survives
33
+ * sanitization, but its CSS is source, not prose — searching it would be noise;
34
+ * see the known-edges note in docs/domains/search.md).
35
+ */
36
+ import {
37
+ DISCARDED_HTML_ELEMENTS,
38
+ DISCARDED_SVG_ELEMENTS,
39
+ imageReadingText,
40
+ type ReadingResolver,
41
+ } from '../core/reading-text.ts';
42
+ import { decodeText } from '../core/text.ts';
43
+
44
+ export type { ReadingResolver } from '../core/reading-text.ts';
45
+
46
+ /** Decodes section bytes honouring the byte-order marks EPUB allows. */
47
+ export { decodeText as decodeSectionBytes } from '../core/text.ts';
48
+
49
+ /**
50
+ * Elements whose content the HTML parser reads as raw text (a `<` in `1<2` inside
51
+ * `<script>` is not a tag): skipping jumps to the first matching end tag rather
52
+ * than tracking nesting. All but `style` are also discarded by the sanitizer;
53
+ * `style` survives into the frame but its text is source, not prose.
54
+ */
55
+ const RAW_TEXT = new Set([
56
+ 'iframe',
57
+ 'noembed',
58
+ 'noframes',
59
+ 'plaintext',
60
+ 'script',
61
+ 'style',
62
+ 'textarea',
63
+ 'title',
64
+ 'xmp',
65
+ ]);
66
+
67
+ /** HTML void elements: no content, no end tag, never on the open-element stack. */
68
+ const VOID = new Set([
69
+ 'area',
70
+ 'base',
71
+ 'br',
72
+ 'col',
73
+ 'embed',
74
+ 'hr',
75
+ 'img',
76
+ 'input',
77
+ 'link',
78
+ 'meta',
79
+ 'param',
80
+ 'source',
81
+ 'track',
82
+ 'wbr',
83
+ ]);
84
+
85
+ /**
86
+ * Block-level tags whose start tag implies `</p>` in HTML parsing. Without this,
87
+ * the unclosed `<p>` real books ship would keep the stack forever nested and the
88
+ * top-level whitespace rule would never apply again.
89
+ */
90
+ const P_CLOSERS = new Set([
91
+ 'address',
92
+ 'article',
93
+ 'aside',
94
+ 'blockquote',
95
+ 'details',
96
+ 'div',
97
+ 'dl',
98
+ 'fieldset',
99
+ 'figcaption',
100
+ 'figure',
101
+ 'footer',
102
+ 'form',
103
+ 'h1',
104
+ 'h2',
105
+ 'h3',
106
+ 'h4',
107
+ 'h5',
108
+ 'h6',
109
+ 'header',
110
+ 'hgroup',
111
+ 'hr',
112
+ 'main',
113
+ 'menu',
114
+ 'nav',
115
+ 'ol',
116
+ 'p',
117
+ 'pre',
118
+ 'section',
119
+ 'table',
120
+ 'ul',
121
+ ]);
122
+
123
+ /**
124
+ * Extracts a section's reading text from its decoded markup — the headless mirror
125
+ * of what the sanitize + resource pipeline leaves for the frame to show (see the
126
+ * module comment for the exact rules). `resolve` is the section's own resolver
127
+ * seam (`Section.resolve`), consulted only to decide whether an `<img>` renders
128
+ * or falls back to its alt text; omit it and every in-book image degrades to alt,
129
+ * matching a section that carries no resolver.
130
+ */
131
+ export function extractText(markup: string, resolve?: ReadingResolver): string {
132
+ let out = '';
133
+ let index = 0;
134
+ /** Open non-void elements. `head` and skipped containers never land here. */
135
+ const stack: string[] = [];
136
+ let svgDepth = 0;
137
+
138
+ // The chunker drops whitespace-only text nodes at the top level of the body
139
+ // (they carry no block box), so the frame's tiled text has no inter-paragraph
140
+ // source whitespace. Mirror it: a whitespace-only run emitted while no element
141
+ // (or only html/body) is open contributes nothing.
142
+ const atTopLevel = (): boolean => {
143
+ const top = stack[stack.length - 1];
144
+ return top === undefined || top === 'html' || top === 'body';
145
+ };
146
+ const emit = (text: string): void => {
147
+ if (text === '') return;
148
+ if (atTopLevel() && text.trim().length === 0) return;
149
+ out += text;
150
+ };
151
+ const popTo = (name: string): void => {
152
+ const at = stack.lastIndexOf(name);
153
+ if (at === -1) return;
154
+ for (let i = stack.length - 1; i >= at; i -= 1) {
155
+ if (stack[i] === 'svg') svgDepth -= 1;
156
+ }
157
+ stack.length = at;
158
+ };
159
+
160
+ const length = markup.length;
161
+ while (index < length) {
162
+ const lt = markup.indexOf('<', index);
163
+ if (lt === -1) {
164
+ emit(decodeEntities(markup.slice(index)));
165
+ break;
166
+ }
167
+ if (lt > index) emit(decodeEntities(markup.slice(index, lt)));
168
+
169
+ // Comment / CDATA / processing instruction / doctype: skip to its close.
170
+ if (markup.startsWith('<!--', lt)) {
171
+ const close = markup.indexOf('-->', lt + 4);
172
+ index = close === -1 ? length : close + 3;
173
+ continue;
174
+ }
175
+ if (markup.startsWith('<![CDATA[', lt)) {
176
+ const close = markup.indexOf(']]>', lt + 9);
177
+ emit(markup.slice(lt + 9, close === -1 ? length : close)); // literal text, no entities
178
+ index = close === -1 ? length : close + 3;
179
+ continue;
180
+ }
181
+ if (markup[lt + 1] === '!' || markup[lt + 1] === '?') {
182
+ const close = markup.indexOf('>', lt + 1);
183
+ index = close === -1 ? length : close + 1;
184
+ continue;
185
+ }
186
+
187
+ const gt = markup.indexOf('>', lt);
188
+ if (gt === -1) {
189
+ // A stray '<' with no closing '>': treat the rest as text.
190
+ emit(decodeEntities(markup.slice(lt)));
191
+ break;
192
+ }
193
+
194
+ const rawTag = markup.slice(lt + 1, gt);
195
+ index = gt + 1;
196
+ const isClose = rawTag[0] === '/';
197
+ const name = tagName(isClose ? rawTag.slice(1) : rawTag);
198
+ if (name === '') continue;
199
+
200
+ if (isClose) {
201
+ popTo(name);
202
+ continue;
203
+ }
204
+
205
+ const selfClosing = rawTag.endsWith('/');
206
+ const inSvg = svgDepth > 0;
207
+
208
+ if (!inSvg) {
209
+ // HTML implied end tags, minimally: a block start tag closes an open <p>,
210
+ // and a list/definition item closes its open sibling. Real books rely on
211
+ // both, and the frame's HTML parse applies them before chunking.
212
+ const top = stack[stack.length - 1];
213
+ if (top === 'p' && P_CLOSERS.has(name)) stack.pop();
214
+ else if (name === 'li' && top === 'li') stack.pop();
215
+ else if ((name === 'dd' || name === 'dt') && (top === 'dd' || top === 'dt')) stack.pop();
216
+ }
217
+
218
+ // The head never reaches the frame body (only its <link>/<style> move, and
219
+ // those carry no reading text), so its whole subtree is skipped.
220
+ if (!inSvg && name === 'head' && !selfClosing) {
221
+ index = skipBalanced(markup, name, index);
222
+ continue;
223
+ }
224
+
225
+ // An <img> renders as an image (zero characters) or becomes its alt text —
226
+ // the one place extraction *adds* text, mirroring the resource layer.
227
+ if (!inSvg && name === 'img') {
228
+ emit(imageReadingText(attributeOf(rawTag, 'src'), attributeOf(rawTag, 'alt'), resolve));
229
+ continue;
230
+ }
231
+
232
+ // Discarded-whole elements contribute nothing, exactly like the sanitizer;
233
+ // raw-text containers jump to their first matching end tag, everything else
234
+ // to the end tag balancing same-name nesting.
235
+ const discarded = inSvg ? DISCARDED_SVG_ELEMENTS : DISCARDED_HTML_ELEMENTS;
236
+ if (discarded.has(name) || name === 'style') {
237
+ if (selfClosing || (!inSvg && VOID.has(name))) continue;
238
+ index = RAW_TEXT.has(name) ? findEndTag(markup, name, index) : skipBalanced(markup, name, index);
239
+ continue;
240
+ }
241
+
242
+ if (selfClosing || (!inSvg && VOID.has(name))) continue;
243
+ if (name === 'svg') svgDepth += 1;
244
+ stack.push(name);
245
+ }
246
+
247
+ return out;
248
+ }
249
+
250
+ /** Decode + extract a section from its raw bytes. */
251
+ export function extractSectionText(bytes: Uint8Array, resolve?: ReadingResolver): string {
252
+ return extractText(decodeText(bytes), resolve);
253
+ }
254
+
255
+ /**
256
+ * Index just past the matching `</name>` end tag at or after `from` (raw-text
257
+ * containers: the first close wins, nothing inside is a tag), or the markup
258
+ * length when the container is never closed. Case-insensitive on the tag name.
259
+ */
260
+ function findEndTag(markup: string, name: string, from: number): number {
261
+ const lower = markup.toLowerCase();
262
+ const needle = `</${name}`;
263
+ let at = from;
264
+ for (;;) {
265
+ const found = lower.indexOf(needle, at);
266
+ if (found === -1) return markup.length;
267
+ const after = markup[found + needle.length];
268
+ // The next char must end the tag name (whitespace or '>'), so </scripting>
269
+ // does not close <script>.
270
+ if (after === undefined || after === '>' || /\s/.test(after)) {
271
+ const gt = markup.indexOf('>', found);
272
+ return gt === -1 ? markup.length : gt + 1;
273
+ }
274
+ at = found + needle.length;
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Index just past the end tag that balances an already-open `name` element,
280
+ * counting same-name nesting (a discarded `<select>` may hold another), or the
281
+ * markup length when it never closes.
282
+ */
283
+ function skipBalanced(markup: string, name: string, from: number): number {
284
+ const lower = markup.toLowerCase();
285
+ const open = `<${name}`;
286
+ const close = `</${name}`;
287
+ let depth = 1;
288
+ let at = from;
289
+ while (depth > 0) {
290
+ const lt = lower.indexOf('<', at);
291
+ if (lt === -1) return markup.length;
292
+ const gt = markup.indexOf('>', lt);
293
+ if (gt === -1) return markup.length;
294
+ if (lower.startsWith(close, lt) && endsTagName(markup, lt + close.length)) {
295
+ depth -= 1;
296
+ } else if (lower.startsWith(open, lt) && endsTagName(markup, lt + open.length)) {
297
+ if (markup[gt - 1] !== '/') depth += 1; // a self-closing one opens nothing
298
+ }
299
+ at = gt + 1;
300
+ }
301
+ return at;
302
+ }
303
+
304
+ /** True when the character at `i` terminates a tag name (`>`, `/`, whitespace, EOF). */
305
+ function endsTagName(markup: string, i: number): boolean {
306
+ const char = markup[i];
307
+ return char === undefined || char === '>' || char === '/' || /\s/.test(char);
308
+ }
309
+
310
+ /** The lowercased element name from a raw tag body ('em class="x"' → 'em'). */
311
+ function tagName(body: string): string {
312
+ let end = 0;
313
+ while (end < body.length && !/[\s/>]/.test(body[end]!)) end += 1;
314
+ return body.slice(0, end).toLowerCase();
315
+ }
316
+
317
+ /**
318
+ * The decoded value of `wanted` in a raw tag body, or `null` when absent.
319
+ * Handles single/double-quoted and unquoted values and a bare attribute name
320
+ * (which the DOM reads as the empty string). Entities in the value are decoded,
321
+ * as an attribute parse would.
322
+ */
323
+ function attributeOf(rawTag: string, wanted: string): string | null {
324
+ let i = 0;
325
+ while (i < rawTag.length && !/[\s/]/.test(rawTag[i]!)) i += 1; // skip the tag name
326
+ while (i < rawTag.length) {
327
+ while (i < rawTag.length && /[\s/]/.test(rawTag[i]!)) i += 1;
328
+ if (i >= rawTag.length) break;
329
+ let start = i;
330
+ while (i < rawTag.length && !/[\s=/]/.test(rawTag[i]!)) i += 1;
331
+ const attribute = rawTag.slice(start, i).toLowerCase();
332
+ while (i < rawTag.length && /\s/.test(rawTag[i]!)) i += 1;
333
+ let value = '';
334
+ if (rawTag[i] === '=') {
335
+ i += 1;
336
+ while (i < rawTag.length && /\s/.test(rawTag[i]!)) i += 1;
337
+ const quote = rawTag[i];
338
+ if (quote === '"' || quote === "'") {
339
+ i += 1;
340
+ start = i;
341
+ while (i < rawTag.length && rawTag[i] !== quote) i += 1;
342
+ value = rawTag.slice(start, i);
343
+ i += 1;
344
+ } else {
345
+ start = i;
346
+ while (i < rawTag.length && !/\s/.test(rawTag[i]!)) i += 1;
347
+ value = rawTag.slice(start, i);
348
+ }
349
+ }
350
+ if (attribute === wanted) return decodeEntities(value);
351
+ }
352
+ return null;
353
+ }
354
+
355
+ const NAMED: ReadonlyMap<string, string> = new Map([
356
+ ['amp', '&'],
357
+ ['lt', '<'],
358
+ ['gt', '>'],
359
+ ['quot', '"'],
360
+ ['apos', "'"],
361
+ ['nbsp', ' '],
362
+ ['mdash', '—'],
363
+ ['ndash', '–'],
364
+ ['hellip', '…'],
365
+ ['lsquo', '‘'],
366
+ ['rsquo', '’'],
367
+ ['ldquo', '“'],
368
+ ['rdquo', '”'],
369
+ ['copy', '©'],
370
+ ['reg', '®'],
371
+ ['trade', '™'],
372
+ ['deg', '°'],
373
+ ['times', '×'],
374
+ ['eacute', 'é'],
375
+ ['egrave', 'è'],
376
+ ['agrave', 'à'],
377
+ ['ccedil', 'ç'],
378
+ ['uuml', 'ü'],
379
+ [' ', ' '],
380
+ ]);
381
+
382
+ /**
383
+ * Decodes numeric (`&#233;`, `&#xe9;`) and the common named HTML entities. An
384
+ * unknown entity is left verbatim — a book's literal `&` in prose stays `&`.
385
+ */
386
+ function decodeEntities(text: string): string {
387
+ if (!text.includes('&')) return text;
388
+ return text.replace(/&(#x?[0-9a-f]+|[a-z][a-z0-9]*);/gi, (whole, body: string) => {
389
+ if (body[0] === '#') {
390
+ const code =
391
+ body[1] === 'x' || body[1] === 'X'
392
+ ? Number.parseInt(body.slice(2), 16)
393
+ : Number.parseInt(body.slice(1), 10);
394
+ if (!Number.isFinite(code) || code < 0 || code > 0x10ffff) return whole;
395
+ try {
396
+ return String.fromCodePoint(code);
397
+ } catch {
398
+ return whole;
399
+ }
400
+ }
401
+ return NAMED.get(body.toLowerCase()) ?? whole;
402
+ });
403
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Full-text search: a headless matcher over decoded section text, yielding jumpable
3
+ * hits. Imports only `src/core` + platform primitives (`DOMParser`, `Intl.Segmenter`,
4
+ * `TextDecoder`), so it runs under `node:test` with no browser and passes
5
+ * `npm run check:core`. The whole-book scan (`searchBook`) is a lazy async generator
6
+ * that never buffers the book whole and cleans up on early `break`.
7
+ */
8
+ export { searchBook, matchText, type SearchHit, type SearchOptions } from './matcher.ts';
9
+ export { extractText, extractSectionText, decodeSectionBytes, type ReadingResolver } from './extract.ts';
10
+ export { normalizeText, normalizeQuery, type NormalizedText } from './normalize.ts';