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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vitaliy Tkachuk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,277 @@
1
+ # wolfy-reader
2
+
3
+ Read ebooks in the browser — with pages that turn, text you can search, and typography you can change without losing your place.
4
+
5
+ A from-scratch, MIT-licensed, ESM-only TypeScript reading engine for EPUB, FB2 and plain text. Bytes in, a `Book` out, and a real reading view: paginated and scrolled modes, table-of-contents and link navigation, full-text search, live theme and typography controls, selection events and decorations. Zero runtime dependencies, and untrusted book content renders inside a hardened sandboxed iframe.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install wolfy-reader
11
+ ```
12
+
13
+ ## Quickstart
14
+
15
+ ```ts
16
+ import { open, render } from 'wolfy-reader';
17
+ import { epub } from 'wolfy-reader/epub';
18
+
19
+ // Bytes in: ArrayBuffer | Blob (a File is one) | { size, read(offset, length) }.
20
+ // The library never fetches — you hand it the bytes.
21
+ const book = await open(file, { formats: [epub] });
22
+
23
+ console.log(book.metadata.title, book.sections.length);
24
+
25
+ const reader = render(book, document.getElementById('reader')!, {
26
+ mode: 'paginated',
27
+ theme: 'sepia',
28
+ fontSize: 19,
29
+ });
30
+
31
+ reader.on('ready', (at) => console.log(`page ${at.page + 1} of ${at.totalPages}`));
32
+
33
+ await reader.next(); // turn a page
34
+ await reader.goTo(book.toc[0]!); // jump to a chapter
35
+ await reader.back(); // undo that jump — there is a back-stack
36
+ ```
37
+
38
+ Formats are explicit: `open` takes the list you pass it, so a text-only app never pulls the EPUB decoder or the ZIP reader. `render` returns synchronously and paints asynchronously — the `ready` event is the first painted page.
39
+
40
+ **Change how it looks, keep the place:**
41
+
42
+ ```ts
43
+ await reader.setAppearance({ fontSize: 24, lineHeight: 1.6, margin: 48 });
44
+ await reader.setMode('scrolled');
45
+ ```
46
+
47
+ Both re-flow the book and then put you back where you were reading. This is a contract, not a best effort: the paragraph at the top of your page is still on your page afterwards, held by a content anchor rather than a page number, and proven by a browser suite that runs every knob against real books.
48
+
49
+ **Search the whole book, lazily:**
50
+
51
+ ```ts
52
+ for await (const hit of reader.search('whale')) {
53
+ console.log(hit.context); // the match, trimmed to whole words
54
+ await reader.goTo(hit.position); // land on its page
55
+ break;
56
+ }
57
+ ```
58
+
59
+ **Highlight a selection:**
60
+
61
+ ```ts
62
+ import { serializePosition } from 'wolfy-reader';
63
+
64
+ reader.on('selection', async ({ text, position }) => {
65
+ await reader.decorate('note-1', position, { className: 'my-highlight' });
66
+ localStorage.setItem('note-1', serializePosition(position)); // your storage, not ours
67
+ });
68
+ ```
69
+
70
+ The library *draws* decorations and never stores them. A `Position` serializes to one opaque string you persist yourself and hand back to `decorate` or `goTo` next time.
71
+
72
+ **Decode without a DOM:**
73
+
74
+ ```ts
75
+ import { open } from 'wolfy-reader/core';
76
+ import { text } from 'wolfy-reader/text';
77
+
78
+ const book = await open(bytes, { formats: [text] });
79
+ ```
80
+
81
+ `wolfy-reader/core` is guaranteed headless — nothing reachable from it touches `document` or `window`, enforced by a static check in CI — so it runs under Node or a worker.
82
+
83
+ ## What you get
84
+
85
+ - **Two reading modes** — paginated with real page turns, or continuous scroll, switchable at runtime.
86
+ - **Navigation** — nested TOC, in-book links, and a back-stack for jumps.
87
+ - **Full-text search** across the whole book, streaming as sections are scanned, with jumpable hits.
88
+ - **Live appearance** — theme, font family and size, line height, margins, one or two columns, justification, hyphenation. Every knob preserves position.
89
+ - **Selection events and decorations** — draw styled overlays over any anchored range; the library never stores annotation data.
90
+ - **Sentence ranges** — `reader.sentences()` returns each sentence with a `Position`, so a host can drive a speech engine sentence by sentence. The library speaks nothing.
91
+ - **Input handled** — keyboard, tap zones and swipe, all configurable.
92
+ - **Typed failures** — every error extends `BookError` (`UnrecognizedFormatError`, `CorruptContainerError`, `EncryptedContentError`), so hosts catch by class.
93
+
94
+ ## Stability contract
95
+
96
+ **The `Book` model and the `Position` format are the promise. Everything else may churn.**
97
+
98
+ Names in the core model are treated as unrenameable, and a reading position serializes to a version-prefixed opaque string a host persists verbatim and never parses — so positions stored today keep resolving as the format evolves. The reader facade grows additively (new methods, never renamed or removed ones).
99
+
100
+ The project lives honestly in `0.x` until the model has survived a real consumer. Until `1.0`, a breaking change bumps the minor.
101
+
102
+ ## Browser support
103
+
104
+ Browsers with `DecompressionStream` — **Chrome 80+, Safari 16.4+, Firefox 113+**. The engine is built on platform primitives (`DecompressionStream`, `DOMParser`, `TextDecoder`, `Intl.Segmenter`, CSS multi-column), which is also what keeps it small.
105
+
106
+ ESM only — no CJS build, no bundler required. The headless `wolfy-reader/core` entry also runs under Node.
107
+
108
+ ## Zero runtime dependencies
109
+
110
+ `dependencies` is empty and stays empty, permanently. Not "few" — none. CI fails the build if anything is added, and a pack-fidelity check installs the published tarball into a scratch project to prove it.
111
+
112
+ Dev dependencies exist (TypeScript, Playwright, esbuild, `@types/node`); none of them ship to you.
113
+
114
+ Published weight, minified and gzipped:
115
+
116
+ | Import | Size |
117
+ | --- | --- |
118
+ | `wolfy-reader` | ~30 kB |
119
+ | `wolfy-reader/core` | ~2.3 kB |
120
+ | `wolfy-reader/epub` | ~6.6 kB |
121
+ | `wolfy-reader/fb2` | ~3.7 kB |
122
+ | `wolfy-reader/text` | ~1.4 kB |
123
+
124
+ Each subpath has a budget CI refuses to exceed. Format decoders are separate entries on purpose — only `epub` carries the ZIP reader.
125
+
126
+ ## Security
127
+
128
+ Book content is untrusted input, and it renders behind three independent defences:
129
+
130
+ - a `sandbox="allow-scripts"` iframe **without** `allow-same-origin`, so the frame's origin is opaque and it can reach nothing of yours;
131
+ - allowlist sanitization of every element and attribute before the markup is assembled;
132
+ - a per-render `Content-Security-Policy` of `default-src 'none'`, with the book's own images, stylesheets and fonts served as `data:` URLs.
133
+
134
+ Host and frame talk over one typed, versioned, validated `postMessage` protocol. The layers are not redundant — see [`docs/domains/view.md`](docs/domains/view.md) for why each is load-bearing.
135
+
136
+ ## Formats and legal position
137
+
138
+ EPUB 2 and 3 (reflowable), FB2, and plain text today; MOBI/AZW3 is planned. Fixed-layout books are *detected* and reported so a host can refuse them — rendering stays reflowable, permanently.
139
+
140
+ **DRM-free books only.** DRM is permanently out of scope: nothing here decrypts, circumvents, or interoperates with a DRM scheme.
141
+
142
+ Formats were learned from specifications — the W3C EPUB specs, the MobileRead format wiki, the PalmDB spec — never from GPL source. No code is taken from epub.js, foliate-js, Readium, PDF.js or JSZip.
143
+
144
+ ## Package surface
145
+
146
+ Five subpaths, written longhand with no wildcards — internal modules are not importable:
147
+
148
+ | Subpath | Contains |
149
+ | --- | --- |
150
+ | `wolfy-reader` | `render` + the reader facade, plus everything in `/core` |
151
+ | `wolfy-reader/core` | `open`, the `Book` model, `Position`, typed errors — guaranteed headless |
152
+ | `wolfy-reader/epub` | the EPUB decoder |
153
+ | `wolfy-reader/fb2` | the FB2 decoder |
154
+ | `wolfy-reader/text` | the plain-text decoder |
155
+
156
+ ## License
157
+
158
+ MIT © Vitaliy Tkachuk
159
+
160
+ ---
161
+
162
+ # Development
163
+
164
+ Everything below is for working *on* the library rather than with it.
165
+
166
+ ## Stack
167
+
168
+ - Language: TypeScript — ESM only (`"type": "module"`), no CJS build
169
+ - Build system: TypeScript compiler (`tsc`), no bundler; `node:test` for tests, Playwright for browser tests (dev-only)
170
+ - Package registry: npm — `wolfy-reader` (unscoped)
171
+ - Target platforms / runtimes: Browsers with `DecompressionStream` — Chrome 80+, Safari 16.4+, Firefox 113+
172
+
173
+ ## Getting Started
174
+
175
+ ```bash
176
+ npm install
177
+ npm run fetch-corpus # download the gitignored test corpus (Gutenberg + Standard Ebooks + W3C)
178
+ npm test # node:test suite, headless — includes the differential decode suite
179
+ npm run test:browser # Playwright suites for the sandboxed frame
180
+ npm run typecheck # tsc --noEmit
181
+ npm run check:core # fail if src/core reaches src/layout or src/view
182
+ npm run check:guards # fail if dependencies grew or consumer vocabulary leaked in
183
+ npm run build # tsc -> dist (plain ESM + .d.ts + sourcemaps), then the @license banner
184
+ npm run check:size # gzipped bundle budget per published subpath (needs a build)
185
+ npm run check:pack # pack, install the tarball, import and typecheck every subpath
186
+ npm run bench # pagination benchmark (needs the corpus)
187
+ ```
188
+
189
+ GitHub Actions runs all of the above on every push to `main` and every pull
190
+ request; the Playwright suites run on pull requests only. Node is pinned in
191
+ `.nvmrc` so local and CI cannot drift. See
192
+ [`docs/domains/release.md`](docs/domains/release.md) for what each guard defends
193
+ and why the browser tier is PR-only.
194
+
195
+ `npm run check:pack` is the only check that exercises the *published* surface: the
196
+ demo and the test suites import `/src/...` by path, which the `exports` map does not
197
+ govern, so a packaging fault is invisible to them. Run it before any release — see
198
+ [`docs/domains/release.md`](docs/domains/release.md).
199
+
200
+ The differential decode suite (`test/differential.test.ts`) proves the decoders
201
+ agree: for each title shipped as both EPUB and TXT, it decodes each, strips
202
+ boilerplate, normalizes, and asserts the prose matches across formats. It is
203
+ headless and **skips gracefully when the corpus is absent** — run `npm run
204
+ fetch-corpus` first to exercise it. This is the safety net MOBI/AZW3 will be
205
+ built against.
206
+
207
+ ### Setup on a new machine
208
+
209
+ A clone gives you the source, the docs, the committed fixtures and the knowledge graph. Four things are deliberately not in the repo and are set up per machine:
210
+
211
+ ```bash
212
+ npm install # 1. required
213
+ npx playwright install chromium # 2. required for `npm run test:browser` (~115 MB)
214
+ npm run fetch-corpus # 3. optional — real books, ~30 MB, gitignored
215
+ graphify hook install # 4. optional — per-clone, see Knowledge graph below
216
+ ```
217
+
218
+ 1. **`npm install`** — `dependencies` is permanently empty; this installs the dev toolchain only.
219
+ 2. **`npx playwright install chromium`** — the `playwright` package has no postinstall step, so `npm install` alone leaves you with no browser and `npm run test:browser` fails. Browser suites launch full Chromium (`channel: 'chromium'`), not the headless shell.
220
+ 3. **`npm run fetch-corpus`** — downloads Project Gutenberg + Standard Ebooks titles into gitignored `test/corpus/`. Skipping it is safe: corpus-backed tests skip gracefully by rule and `npm test` stays green. Without it, `npm run bench` skips and the benchmark fixtures cannot be generated.
221
+ 4. **`graphify hook install`** — the git hooks that rebuild the knowledge graph are local-only; a fresh clone has none until you install them.
222
+
223
+ Everything above is regenerable. The one file that is not in the repo and cannot be rebuilt is the local `PLAN.md` implementation plan — copy it across by hand if you are moving machines mid-project.
224
+
225
+ > Benchmark timings recorded in [`docs/domains/layout.md`](docs/domains/layout.md) are machine-specific. On different hardware, re-baseline with `npm run bench` and compare the chunked-vs-naive *ratios* (3–7x, far above machine variance) rather than the absolute milliseconds.
226
+
227
+ ## Running Locally
228
+
229
+ ```bash
230
+ npm run demo
231
+ ```
232
+
233
+ This starts a dependency-free dev server (Node built-ins only) and prints the URL — `http://localhost:8080/` by default, or the next free port if 8080 is busy. It serves exactly two directories: `demo/` (at `/`, so `/` opens the demo) and `src/` (at `/src/`, so the demo can import library modules); no other repo path is reachable. `.ts` files are served with type annotations stripped (`node:module`'s `stripTypeScriptTypes`), so the browser runs the TypeScript sources directly — no bundler, no build step.
234
+
235
+ The demo is the primary development surface: pick an EPUB and it opens the book through the public API and shows what the decoder produced — metadata and cover, the nested table of contents, and the reading order. Clicking a chapter renders it as live markup inside the hardened sandboxed frame, with the book's own stylesheets and images, next to a panel listing everything the sanitizer removed and everything the Content Security Policy blocked. Decode failures surface as the library's typed errors by class name. It stays framework-free by rule. Opening `demo/index.html` as a `file://` URL does not work — browsers block ES module loading over `file://`.
236
+
237
+ ## Building & Releasing
238
+
239
+ ```bash
240
+ npm run build # tsc -> dist (plain ESM + .d.ts + sourcemaps), then the @license banner
241
+ npm run check:pack # pack, install the tarball, import and typecheck every subpath
242
+ ```
243
+
244
+ Releases are pull requests. [release-please](https://github.com/googleapis/release-please)
245
+ keeps one open on `main`, carrying the version bump and the `CHANGELOG.md` entry it
246
+ derives from the Conventional Commit subjects since the last tag. Merging it tags the
247
+ release and publishes to npm from GitHub Actions, authenticated by OIDC trusted
248
+ publishing with provenance — there is no `NPM_TOKEN` in this repository and no manual
249
+ `npm publish` step. The publish job re-runs the full check suite first, because a
250
+ bot-opened pull request does not run CI until a maintainer approves it. After
251
+ publishing, a smoke job installs the released version from the registry and
252
+ imports every subpath
253
+ (`npm run check:pack -- --from-registry=<version>`).
254
+
255
+ See [`docs/domains/release.md`](docs/domains/release.md) for the flow and the
256
+ one-time bootstrap that had to be done by hand.
257
+
258
+ ## Working with AI agents
259
+
260
+ This repo uses a [lightweight spec-driven workflow](https://github.com/vitaliy-tkachuk/ai-spec-template). AI agents (Claude Code, Cursor, Codex, Copilot, Aider, etc.) follow the rules in [`AGENTS.md`](AGENTS.md).
261
+
262
+ Key docs:
263
+
264
+ - [`AGENTS.md`](AGENTS.md) — canonical instructions for AI agents
265
+ - [`docs/feature-workflow.md`](docs/feature-workflow.md) — complexity levels (0–3) and required artifacts
266
+ - [`docs/architecture.md`](docs/architecture.md) — system architecture
267
+ - [`docs/coding-conventions.md`](docs/coding-conventions.md) — coding conventions
268
+ - [`docs/patterns.md`](docs/patterns.md) — cross-domain reusable patterns
269
+ - [`docs/domains/`](docs/domains/) — per-domain durable knowledge (the permanent record)
270
+
271
+ Architectural decisions live in `docs/architecture.md`. Task files (`docs/tasks/`) are ephemeral, local-only, and gitignored.
272
+
273
+ ### Knowledge graph
274
+
275
+ If `graphify-out/graph.json` is present, it is a committed [graphify](https://github.com/safishamsi/graphify) knowledge graph of this repo. Agents query it before reading files (`graphify query "<question>"`), which is far cheaper than sweeping the tree — see [`AGENTS.md`](AGENTS.md#knowledge-graph--query-it-first-to-save-tokens).
276
+
277
+ The graph auto-rebuilds after each commit via local git hooks. Those hooks are not committed, so run `graphify hook install` once per clone. If the repo has no graph yet, build one with `/graphify .` and commit `graphify-out/`.
@@ -0,0 +1,75 @@
1
+ /** A lazily loaded binary payload with its media type. */
2
+ export interface Resource {
3
+ readonly mediaType: string;
4
+ load(): Promise<Uint8Array>;
5
+ }
6
+ /** Fields the format could not determine are omitted, never set to undefined. */
7
+ export interface BookMetadata {
8
+ readonly title?: string;
9
+ readonly author?: string;
10
+ /** BCP 47 language tag. */
11
+ readonly language?: string;
12
+ readonly cover?: Resource;
13
+ }
14
+ /** A table-of-contents entry; targets a section by id, never by file path. */
15
+ export interface TocItem {
16
+ readonly label: string;
17
+ readonly sectionId: string;
18
+ /** Anchor within the section, when the format provides one. */
19
+ readonly fragment?: string;
20
+ readonly children: readonly TocItem[];
21
+ }
22
+ /** One unit of the book's reading order. */
23
+ export interface Section {
24
+ /** Unique within the book. */
25
+ readonly id: string;
26
+ readonly mediaType: string;
27
+ /** True when the content declares executable script; omitted when unknown. */
28
+ readonly scripted?: boolean;
29
+ load(): Promise<Uint8Array>;
30
+ /**
31
+ * Resolves a reference appearing inside this section's content — in whatever
32
+ * form the format uses — to the resource it names. Returns undefined when the
33
+ * reference names nothing this book can supply. Omitted by formats that
34
+ * cannot resolve references.
35
+ */
36
+ resolve?(reference: string): Resource | undefined;
37
+ /**
38
+ * Resolves a reference appearing inside this section's content to the *section*
39
+ * it targets (plus any fragment), or undefined when it names no section in this
40
+ * book. The counterpart to {@link resolve}: `resolve` hands back a `Resource`'s
41
+ * bytes for a reference (an image, a stylesheet), this hands back a navigation
42
+ * target for a reference to another reading unit — a "next chapter" link, a
43
+ * table-of-contents entry rendered in-content. Section-relative (a bare
44
+ * `chapter-2.xhtml` resolves against this section's own location) and path-free
45
+ * on return, so container paths never enter the model. Omitted by formats whose
46
+ * references cannot name a section (plain text has none).
47
+ */
48
+ resolveHref?(reference: string): SectionRef | undefined;
49
+ }
50
+ /**
51
+ * A resolved in-content reference: the section it targets, plus any fragment the
52
+ * reference carried. Path-free by design — the return names a section by id, never
53
+ * by container path, so paths never enter the model.
54
+ */
55
+ export interface SectionRef {
56
+ readonly sectionId: string;
57
+ /** Anchor within the target section, when the reference carried one. */
58
+ readonly fragment?: string;
59
+ }
60
+ /** Reading order of the pages: left-to-right or right-to-left. */
61
+ export type ReadingDirection = 'ltr' | 'rtl';
62
+ export interface Book {
63
+ readonly metadata: BookMetadata;
64
+ readonly toc: readonly TocItem[];
65
+ /** Reading order. */
66
+ readonly sections: readonly Section[];
67
+ section(id: string): Section | undefined;
68
+ /** Non-section payloads (images, stylesheets) keyed by format-defined id. */
69
+ readonly resources: ReadonlyMap<string, Resource>;
70
+ /** Declared reading direction; omitted when the book does not declare one. */
71
+ readonly direction?: ReadingDirection;
72
+ /** True when the book declares fixed-size pages. Detection only — rendering stays reflowable. */
73
+ readonly fixedLayout?: boolean;
74
+ }
75
+ //# sourceMappingURL=book.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"book.d.ts","sourceRoot":"","sources":["../../src/core/book.ts"],"names":[],"mappings":"AAAA,0DAA0D;AAC1D,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,IAAI,IAAI,OAAO,CAAC,UAAU,CAAC,CAAC;CAC7B;AAED,iFAAiF;AACjF,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,2BAA2B;IAC3B,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,KAAK,CAAC,EAAE,QAAQ,CAAC;CAC3B;AAED,8EAA8E;AAC9E,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,+DAA+D;IAC/D,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,QAAQ,EAAE,SAAS,OAAO,EAAE,CAAC;CACvC;AAED,4CAA4C;AAC5C,MAAM,WAAW,OAAO;IACtB,8BAA8B;IAC9B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,8EAA8E;IAC9E,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;IAC5B,IAAI,IAAI,OAAO,CAAC,UAAU,CAAC,CAAC;IAC5B;;;;;OAKG;IACH,OAAO,CAAC,CAAC,SAAS,EAAE,MAAM,GAAG,QAAQ,GAAG,SAAS,CAAC;IAClD;;;;;;;;;;OAUG;IACH,WAAW,CAAC,CAAC,SAAS,EAAE,MAAM,GAAG,UAAU,GAAG,SAAS,CAAC;CACzD;AAED;;;;GAIG;AACH,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,wEAAwE;IACxE,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,kEAAkE;AAClE,MAAM,MAAM,gBAAgB,GAAG,KAAK,GAAG,KAAK,CAAC;AAE7C,MAAM,WAAW,IAAI;IACnB,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAC;IAChC,QAAQ,CAAC,GAAG,EAAE,SAAS,OAAO,EAAE,CAAC;IACjC,qBAAqB;IACrB,QAAQ,CAAC,QAAQ,EAAE,SAAS,OAAO,EAAE,CAAC;IACtC,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,GAAG,SAAS,CAAC;IACzC,6EAA6E;IAC7E,QAAQ,CAAC,SAAS,EAAE,WAAW,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;IAClD,8EAA8E;IAC9E,QAAQ,CAAC,SAAS,CAAC,EAAE,gBAAgB,CAAC;IACtC,iGAAiG;IACjG,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,CAAC;CAChC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=book.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"book.js","sourceRoot":"","sources":["../../src/core/book.ts"],"names":[],"mappings":""}
@@ -0,0 +1,17 @@
1
+ /** Base class for every error this library throws. */
2
+ export declare class BookError extends Error {
3
+ constructor(message: string, options?: ErrorOptions);
4
+ }
5
+ /** No registered format claimed the input bytes. */
6
+ export declare class UnrecognizedFormatError extends BookError {
7
+ constructor(message: string, options?: ErrorOptions);
8
+ }
9
+ /** The container structure is damaged or malformed. */
10
+ export declare class CorruptContainerError extends BookError {
11
+ constructor(message: string, options?: ErrorOptions);
12
+ }
13
+ /** The content is encrypted; DRM-free books only. */
14
+ export declare class EncryptedContentError extends BookError {
15
+ constructor(message: string, options?: ErrorOptions);
16
+ }
17
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/core/errors.ts"],"names":[],"mappings":"AAAA,sDAAsD;AACtD,qBAAa,SAAU,SAAQ,KAAK;IAClC,YAAY,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY,EAGlD;CACF;AAED,oDAAoD;AACpD,qBAAa,uBAAwB,SAAQ,SAAS;IACpD,YAAY,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY,EAGlD;CACF;AAED,uDAAuD;AACvD,qBAAa,qBAAsB,SAAQ,SAAS;IAClD,YAAY,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY,EAGlD;CACF;AAED,qDAAqD;AACrD,qBAAa,qBAAsB,SAAQ,SAAS;IAClD,YAAY,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,YAAY,EAGlD;CACF"}
@@ -0,0 +1,29 @@
1
+ /** Base class for every error this library throws. */
2
+ export class BookError extends Error {
3
+ constructor(message, options) {
4
+ super(message, options);
5
+ this.name = 'BookError';
6
+ }
7
+ }
8
+ /** No registered format claimed the input bytes. */
9
+ export class UnrecognizedFormatError extends BookError {
10
+ constructor(message, options) {
11
+ super(message, options);
12
+ this.name = 'UnrecognizedFormatError';
13
+ }
14
+ }
15
+ /** The container structure is damaged or malformed. */
16
+ export class CorruptContainerError extends BookError {
17
+ constructor(message, options) {
18
+ super(message, options);
19
+ this.name = 'CorruptContainerError';
20
+ }
21
+ }
22
+ /** The content is encrypted; DRM-free books only. */
23
+ export class EncryptedContentError extends BookError {
24
+ constructor(message, options) {
25
+ super(message, options);
26
+ this.name = 'EncryptedContentError';
27
+ }
28
+ }
29
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["../../src/core/errors.ts"],"names":[],"mappings":"AAAA,sDAAsD;AACtD,MAAM,OAAO,SAAU,SAAQ,KAAK;IAClC,YAAY,OAAe,EAAE,OAAsB;QACjD,KAAK,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QACxB,IAAI,CAAC,IAAI,GAAG,WAAW,CAAC;IAC1B,CAAC;CACF;AAED,oDAAoD;AACpD,MAAM,OAAO,uBAAwB,SAAQ,SAAS;IACpD,YAAY,OAAe,EAAE,OAAsB;QACjD,KAAK,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QACxB,IAAI,CAAC,IAAI,GAAG,yBAAyB,CAAC;IACxC,CAAC;CACF;AAED,uDAAuD;AACvD,MAAM,OAAO,qBAAsB,SAAQ,SAAS;IAClD,YAAY,OAAe,EAAE,OAAsB;QACjD,KAAK,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QACxB,IAAI,CAAC,IAAI,GAAG,uBAAuB,CAAC;IACtC,CAAC;CACF;AAED,qDAAqD;AACrD,MAAM,OAAO,qBAAsB,SAAQ,SAAS;IAClD,YAAY,OAAe,EAAE,OAAsB;QACjD,KAAK,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QACxB,IAAI,CAAC,IAAI,GAAG,uBAAuB,CAAC;IACtC,CAAC;CACF"}
@@ -0,0 +1,13 @@
1
+ import type { Book } from './book.ts';
2
+ import type { ByteSource } from './source.ts';
3
+ import type { StorageAdapter } from './storage.ts';
4
+ export interface FormatContext {
5
+ readonly storage?: StorageAdapter;
6
+ }
7
+ /** A registrable format: claims input via sniff, then decodes it to a Book. */
8
+ export interface BookFormat {
9
+ readonly name: string;
10
+ sniff(source: ByteSource): boolean | Promise<boolean>;
11
+ decode(source: ByteSource, context: FormatContext): Promise<Book>;
12
+ }
13
+ //# sourceMappingURL=format.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"format.d.ts","sourceRoot":"","sources":["../../src/core/format.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACtC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAEnD,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,OAAO,CAAC,EAAE,cAAc,CAAC;CACnC;AAED,+EAA+E;AAC/E,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,KAAK,CAAC,MAAM,EAAE,UAAU,GAAG,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACtD,MAAM,CAAC,MAAM,EAAE,UAAU,EAAE,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACnE"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=format.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"format.js","sourceRoot":"","sources":["../../src/core/format.ts"],"names":[],"mappings":""}
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Shared grapheme-boundary helper for the position layer (`src/core/position.ts`)
3
+ * and the paginator (`src/layout/index.ts`). Internal: nothing here is re-exported
4
+ * from `src/core/index.ts`, so it is not part of the frozen public surface.
5
+ *
6
+ * A segmented text is represented as a `Grapheme[]` whose `.index` values form a
7
+ * sorted array of UTF-16 code-unit boundaries; both offset↔index conversions are
8
+ * binary searches over that array. Segment once per text, convert O(log n) per
9
+ * call — never re-segment per lookup (the batch-capture invariant).
10
+ *
11
+ * Headless and pure `Intl.Segmenter`: grapheme segmentation is locale-independent,
12
+ * so one shared segmenter instance serves every caller.
13
+ */
14
+ /** A grapheme cluster together with its UTF-16 code-unit offset in the text. */
15
+ export interface Grapheme {
16
+ readonly segment: string;
17
+ readonly index: number;
18
+ }
19
+ /** Segments `text` into grapheme clusters, each keeping its code-unit index. */
20
+ export declare function segmentGraphemes(text: string): Grapheme[];
21
+ /** Number of grapheme clusters in `text`. */
22
+ export declare function countGraphemes(text: string): number;
23
+ /**
24
+ * Maps a UTF-16 offset to the grapheme index at or after it: the first index
25
+ * whose boundary is >= `offset`, or the grapheme count when `offset` is past
26
+ * every boundary. Binary search over the sorted `.index` boundaries.
27
+ */
28
+ export declare function codeUnitOffsetToGraphemeIndex(graphemes: readonly Grapheme[], offset: number): number;
29
+ /**
30
+ * Maps a grapheme index to its UTF-16 code-unit offset. An index at or past the
31
+ * end maps to `textLength` (the caller supplies it because a boundary array does
32
+ * not know where the final cluster ends). Exact for surrogate pairs, combining
33
+ * sequences, and emoji clusters, because each grapheme carries its real index.
34
+ */
35
+ export declare function graphemeIndexToCodeUnitOffset(graphemes: readonly Grapheme[], textLength: number, graphemeIndex: number): number;
36
+ //# sourceMappingURL=graphemes.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"graphemes.d.ts","sourceRoot":"","sources":["../../src/core/graphemes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,gFAAgF;AAChF,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;CACxB;AAID,gFAAgF;AAChF,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ,EAAE,CAMzD;AAED,6CAA6C;AAC7C,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAInD;AAED;;;;GAIG;AACH,wBAAgB,6BAA6B,CAC3C,SAAS,EAAE,SAAS,QAAQ,EAAE,EAC9B,MAAM,EAAE,MAAM,GACb,MAAM,CAUR;AAED;;;;;GAKG;AACH,wBAAgB,6BAA6B,CAC3C,SAAS,EAAE,SAAS,QAAQ,EAAE,EAC9B,UAAU,EAAE,MAAM,EAClB,aAAa,EAAE,MAAM,GACpB,MAAM,CAIR"}
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Shared grapheme-boundary helper for the position layer (`src/core/position.ts`)
3
+ * and the paginator (`src/layout/index.ts`). Internal: nothing here is re-exported
4
+ * from `src/core/index.ts`, so it is not part of the frozen public surface.
5
+ *
6
+ * A segmented text is represented as a `Grapheme[]` whose `.index` values form a
7
+ * sorted array of UTF-16 code-unit boundaries; both offset↔index conversions are
8
+ * binary searches over that array. Segment once per text, convert O(log n) per
9
+ * call — never re-segment per lookup (the batch-capture invariant).
10
+ *
11
+ * Headless and pure `Intl.Segmenter`: grapheme segmentation is locale-independent,
12
+ * so one shared segmenter instance serves every caller.
13
+ */
14
+ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
15
+ /** Segments `text` into grapheme clusters, each keeping its code-unit index. */
16
+ export function segmentGraphemes(text) {
17
+ const out = [];
18
+ for (const segment of graphemeSegmenter.segment(text)) {
19
+ out.push({ segment: segment.segment, index: segment.index });
20
+ }
21
+ return out;
22
+ }
23
+ /** Number of grapheme clusters in `text`. */
24
+ export function countGraphemes(text) {
25
+ let count = 0;
26
+ for (const _ of graphemeSegmenter.segment(text))
27
+ count += 1;
28
+ return count;
29
+ }
30
+ /**
31
+ * Maps a UTF-16 offset to the grapheme index at or after it: the first index
32
+ * whose boundary is >= `offset`, or the grapheme count when `offset` is past
33
+ * every boundary. Binary search over the sorted `.index` boundaries.
34
+ */
35
+ export function codeUnitOffsetToGraphemeIndex(graphemes, offset) {
36
+ if (offset <= 0)
37
+ return 0;
38
+ let low = 0;
39
+ let high = graphemes.length;
40
+ while (low < high) {
41
+ const mid = (low + high) >>> 1;
42
+ if (graphemes[mid].index >= offset)
43
+ high = mid;
44
+ else
45
+ low = mid + 1;
46
+ }
47
+ return low;
48
+ }
49
+ /**
50
+ * Maps a grapheme index to its UTF-16 code-unit offset. An index at or past the
51
+ * end maps to `textLength` (the caller supplies it because a boundary array does
52
+ * not know where the final cluster ends). Exact for surrogate pairs, combining
53
+ * sequences, and emoji clusters, because each grapheme carries its real index.
54
+ */
55
+ export function graphemeIndexToCodeUnitOffset(graphemes, textLength, graphemeIndex) {
56
+ if (graphemeIndex <= 0)
57
+ return 0;
58
+ if (graphemeIndex >= graphemes.length)
59
+ return textLength;
60
+ return graphemes[graphemeIndex].index;
61
+ }
62
+ //# sourceMappingURL=graphemes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"graphemes.js","sourceRoot":"","sources":["../../src/core/graphemes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAQH,MAAM,iBAAiB,GAAG,IAAI,IAAI,CAAC,SAAS,CAAC,SAAS,EAAE,EAAE,WAAW,EAAE,UAAU,EAAE,CAAC,CAAC;AAErF,gFAAgF;AAChF,MAAM,UAAU,gBAAgB,CAAC,IAAY;IAC3C,MAAM,GAAG,GAAe,EAAE,CAAC;IAC3B,KAAK,MAAM,OAAO,IAAI,iBAAiB,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;QACtD,GAAG,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,KAAK,EAAE,OAAO,CAAC,KAAK,EAAE,CAAC,CAAC;IAC/D,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED,6CAA6C;AAC7C,MAAM,UAAU,cAAc,CAAC,IAAY;IACzC,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,KAAK,MAAM,CAAC,IAAI,iBAAiB,CAAC,OAAO,CAAC,IAAI,CAAC;QAAE,KAAK,IAAI,CAAC,CAAC;IAC5D,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,6BAA6B,CAC3C,SAA8B,EAC9B,MAAc;IAEd,IAAI,MAAM,IAAI,CAAC;QAAE,OAAO,CAAC,CAAC;IAC1B,IAAI,GAAG,GAAG,CAAC,CAAC;IACZ,IAAI,IAAI,GAAG,SAAS,CAAC,MAAM,CAAC;IAC5B,OAAO,GAAG,GAAG,IAAI,EAAE,CAAC;QAClB,MAAM,GAAG,GAAG,CAAC,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC;QAC/B,IAAI,SAAS,CAAC,GAAG,CAAE,CAAC,KAAK,IAAI,MAAM;YAAE,IAAI,GAAG,GAAG,CAAC;;YAC3C,GAAG,GAAG,GAAG,GAAG,CAAC,CAAC;IACrB,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,6BAA6B,CAC3C,SAA8B,EAC9B,UAAkB,EAClB,aAAqB;IAErB,IAAI,aAAa,IAAI,CAAC;QAAE,OAAO,CAAC,CAAC;IACjC,IAAI,aAAa,IAAI,SAAS,CAAC,MAAM;QAAE,OAAO,UAAU,CAAC;IACzD,OAAO,SAAS,CAAC,aAAa,CAAE,CAAC,KAAK,CAAC;AACzC,CAAC"}
@@ -0,0 +1,11 @@
1
+ export type { Book, BookMetadata, ReadingDirection, Resource, Section, TocItem } from './book.ts';
2
+ export { BookError, CorruptContainerError, EncryptedContentError, UnrecognizedFormatError, } from './errors.ts';
3
+ export type { BookFormat, FormatContext } from './format.ts';
4
+ export { open } from './open.ts';
5
+ export { capturePosition, parsePosition, resolvePosition, segmentSentences, serializePosition, } from './position.ts';
6
+ export type { CapturePositionOptions, Position, ResolvedPosition, SentenceRange, TextAnchor, } from './position.ts';
7
+ export type { OpenOptions } from './open.ts';
8
+ export { toByteSource } from './source.ts';
9
+ export type { BookInput, ByteSource, RangeRead, RangeReader } from './source.ts';
10
+ export type { StorageAdapter } from './storage.ts';
11
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/core/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,IAAI,EAAE,YAAY,EAAE,gBAAgB,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAClG,OAAO,EACL,SAAS,EACT,qBAAqB,EACrB,qBAAqB,EACrB,uBAAuB,GACxB,MAAM,aAAa,CAAC;AACrB,YAAY,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC7D,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EACL,eAAe,EACf,aAAa,EACb,eAAe,EACf,gBAAgB,EAChB,iBAAiB,GAClB,MAAM,eAAe,CAAC;AACvB,YAAY,EACV,sBAAsB,EACtB,QAAQ,EACR,gBAAgB,EAChB,aAAa,EACb,UAAU,GACX,MAAM,eAAe,CAAC;AACvB,YAAY,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAC7C,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,YAAY,EAAE,SAAS,EAAE,UAAU,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AACjF,YAAY,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC"}
@@ -0,0 +1,6 @@
1
+ /*! wolfy-reader v0.1.0 | @license MIT | (c) Vitaliy Tkachuk | github.com/vitaliy-tkachuk/wolfy-reader */
2
+ export { BookError, CorruptContainerError, EncryptedContentError, UnrecognizedFormatError, } from './errors.js';
3
+ export { open } from './open.js';
4
+ export { capturePosition, parsePosition, resolvePosition, segmentSentences, serializePosition, } from './position.js';
5
+ export { toByteSource } from './source.js';
6
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/core/index.ts"],"names":[],"mappings":";AACA,OAAO,EACL,SAAS,EACT,qBAAqB,EACrB,qBAAqB,EACrB,uBAAuB,GACxB,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EACL,eAAe,EACf,aAAa,EACb,eAAe,EACf,gBAAgB,EAChB,iBAAiB,GAClB,MAAM,eAAe,CAAC;AASvB,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC"}
@@ -0,0 +1,10 @@
1
+ import type { Section } from './book.ts';
2
+ /**
3
+ * A `Map`-backed `Book.section(id)` implementation shared by the decoders, so an
4
+ * id lookup is O(1) instead of a linear scan per call. First match wins on a
5
+ * duplicate id, matching the `Array.find` semantics it replaced (section ids are
6
+ * unique by the model's rules; this only pins the behavior if a decoder ever
7
+ * slips). Internal to the library — not re-exported from `src/core/index.ts`.
8
+ */
9
+ export declare function sectionLookup(sections: readonly Section[]): (id: string) => Section | undefined;
10
+ //# sourceMappingURL=lookup.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lookup.d.ts","sourceRoot":"","sources":["../../src/core/lookup.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEzC;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,SAAS,OAAO,EAAE,GAAG,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,GAAG,SAAS,CAM/F"}
@@ -0,0 +1,16 @@
1
+ /**
2
+ * A `Map`-backed `Book.section(id)` implementation shared by the decoders, so an
3
+ * id lookup is O(1) instead of a linear scan per call. First match wins on a
4
+ * duplicate id, matching the `Array.find` semantics it replaced (section ids are
5
+ * unique by the model's rules; this only pins the behavior if a decoder ever
6
+ * slips). Internal to the library — not re-exported from `src/core/index.ts`.
7
+ */
8
+ export function sectionLookup(sections) {
9
+ const byId = new Map();
10
+ for (const section of sections) {
11
+ if (!byId.has(section.id))
12
+ byId.set(section.id, section);
13
+ }
14
+ return (id) => byId.get(id);
15
+ }
16
+ //# sourceMappingURL=lookup.js.map