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
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 @@
|
|
|
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 @@
|
|
|
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
|