wolfy-reader 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +277 -0
  3. package/dist/core/book.d.ts +75 -0
  4. package/dist/core/book.d.ts.map +1 -0
  5. package/dist/core/book.js +2 -0
  6. package/dist/core/book.js.map +1 -0
  7. package/dist/core/errors.d.ts +17 -0
  8. package/dist/core/errors.d.ts.map +1 -0
  9. package/dist/core/errors.js +29 -0
  10. package/dist/core/errors.js.map +1 -0
  11. package/dist/core/format.d.ts +13 -0
  12. package/dist/core/format.d.ts.map +1 -0
  13. package/dist/core/format.js +2 -0
  14. package/dist/core/format.js.map +1 -0
  15. package/dist/core/graphemes.d.ts +36 -0
  16. package/dist/core/graphemes.d.ts.map +1 -0
  17. package/dist/core/graphemes.js +62 -0
  18. package/dist/core/graphemes.js.map +1 -0
  19. package/dist/core/index.d.ts +11 -0
  20. package/dist/core/index.d.ts.map +1 -0
  21. package/dist/core/index.js +6 -0
  22. package/dist/core/index.js.map +1 -0
  23. package/dist/core/lookup.d.ts +10 -0
  24. package/dist/core/lookup.d.ts.map +1 -0
  25. package/dist/core/lookup.js +16 -0
  26. package/dist/core/lookup.js.map +1 -0
  27. package/dist/core/open.d.ts +12 -0
  28. package/dist/core/open.d.ts.map +1 -0
  29. package/dist/core/open.js +23 -0
  30. package/dist/core/open.js.map +1 -0
  31. package/dist/core/position.d.ts +121 -0
  32. package/dist/core/position.d.ts.map +1 -0
  33. package/dist/core/position.js +327 -0
  34. package/dist/core/position.js.map +1 -0
  35. package/dist/core/reading-text.d.ts +77 -0
  36. package/dist/core/reading-text.d.ts.map +1 -0
  37. package/dist/core/reading-text.js +144 -0
  38. package/dist/core/reading-text.js.map +1 -0
  39. package/dist/core/source.d.ts +17 -0
  40. package/dist/core/source.d.ts.map +1 -0
  41. package/dist/core/source.js +39 -0
  42. package/dist/core/source.js.map +1 -0
  43. package/dist/core/storage.d.ts +7 -0
  44. package/dist/core/storage.d.ts.map +1 -0
  45. package/dist/core/storage.js +2 -0
  46. package/dist/core/storage.js.map +1 -0
  47. package/dist/core/text.d.ts +18 -0
  48. package/dist/core/text.d.ts.map +1 -0
  49. package/dist/core/text.js +32 -0
  50. package/dist/core/text.js.map +1 -0
  51. package/dist/formats/epub/href.d.ts +14 -0
  52. package/dist/formats/epub/href.d.ts.map +1 -0
  53. package/dist/formats/epub/href.js +52 -0
  54. package/dist/formats/epub/href.js.map +1 -0
  55. package/dist/formats/epub/index.d.ts +3 -0
  56. package/dist/formats/epub/index.d.ts.map +1 -0
  57. package/dist/formats/epub/index.js +223 -0
  58. package/dist/formats/epub/index.js.map +1 -0
  59. package/dist/formats/epub/opf.d.ts +27 -0
  60. package/dist/formats/epub/opf.d.ts.map +1 -0
  61. package/dist/formats/epub/opf.js +110 -0
  62. package/dist/formats/epub/opf.js.map +1 -0
  63. package/dist/formats/epub/toc.d.ts +6 -0
  64. package/dist/formats/epub/toc.d.ts.map +1 -0
  65. package/dist/formats/epub/toc.js +88 -0
  66. package/dist/formats/epub/toc.js.map +1 -0
  67. package/dist/formats/fb2/index.d.ts +13 -0
  68. package/dist/formats/fb2/index.d.ts.map +1 -0
  69. package/dist/formats/fb2/index.js +317 -0
  70. package/dist/formats/fb2/index.js.map +1 -0
  71. package/dist/formats/index.d.ts +4 -0
  72. package/dist/formats/index.d.ts.map +1 -0
  73. package/dist/formats/index.js +4 -0
  74. package/dist/formats/index.js.map +1 -0
  75. package/dist/formats/text/index.d.ts +11 -0
  76. package/dist/formats/text/index.d.ts.map +1 -0
  77. package/dist/formats/text/index.js +201 -0
  78. package/dist/formats/text/index.js.map +1 -0
  79. package/dist/formats/xml.d.ts +33 -0
  80. package/dist/formats/xml.d.ts.map +1 -0
  81. package/dist/formats/xml.js +321 -0
  82. package/dist/formats/xml.js.map +1 -0
  83. package/dist/index.d.ts +27 -0
  84. package/dist/index.d.ts.map +1 -0
  85. package/dist/index.js +28 -0
  86. package/dist/index.js.map +1 -0
  87. package/dist/layout/chunk.d.ts +51 -0
  88. package/dist/layout/chunk.d.ts.map +1 -0
  89. package/dist/layout/chunk.js +131 -0
  90. package/dist/layout/chunk.js.map +1 -0
  91. package/dist/layout/index.d.ts +238 -0
  92. package/dist/layout/index.d.ts.map +1 -0
  93. package/dist/layout/index.js +476 -0
  94. package/dist/layout/index.js.map +1 -0
  95. package/dist/reader/index.d.ts +231 -0
  96. package/dist/reader/index.d.ts.map +1 -0
  97. package/dist/reader/index.js +958 -0
  98. package/dist/reader/index.js.map +1 -0
  99. package/dist/reader/input.d.ts +69 -0
  100. package/dist/reader/input.d.ts.map +1 -0
  101. package/dist/reader/input.js +75 -0
  102. package/dist/reader/input.js.map +1 -0
  103. package/dist/search/extract.d.ts +51 -0
  104. package/dist/search/extract.d.ts.map +1 -0
  105. package/dist/search/extract.js +401 -0
  106. package/dist/search/extract.js.map +1 -0
  107. package/dist/search/index.d.ts +11 -0
  108. package/dist/search/index.d.ts.map +1 -0
  109. package/dist/search/index.js +11 -0
  110. package/dist/search/index.js.map +1 -0
  111. package/dist/search/matcher.d.ts +56 -0
  112. package/dist/search/matcher.d.ts.map +1 -0
  113. package/dist/search/matcher.js +128 -0
  114. package/dist/search/matcher.js.map +1 -0
  115. package/dist/search/normalize.d.ts +48 -0
  116. package/dist/search/normalize.d.ts.map +1 -0
  117. package/dist/search/normalize.js +103 -0
  118. package/dist/search/normalize.js.map +1 -0
  119. package/dist/view/allowlist.d.ts +59 -0
  120. package/dist/view/allowlist.d.ts.map +1 -0
  121. package/dist/view/allowlist.js +198 -0
  122. package/dist/view/allowlist.js.map +1 -0
  123. package/dist/view/appearance.d.ts +137 -0
  124. package/dist/view/appearance.d.ts.map +1 -0
  125. package/dist/view/appearance.js +274 -0
  126. package/dist/view/appearance.js.map +1 -0
  127. package/dist/view/css.d.ts +24 -0
  128. package/dist/view/css.d.ts.map +1 -0
  129. package/dist/view/css.js +153 -0
  130. package/dist/view/css.js.map +1 -0
  131. package/dist/view/frame.d.ts +54 -0
  132. package/dist/view/frame.d.ts.map +1 -0
  133. package/dist/view/frame.js +980 -0
  134. package/dist/view/frame.js.map +1 -0
  135. package/dist/view/host.d.ts +160 -0
  136. package/dist/view/host.d.ts.map +1 -0
  137. package/dist/view/host.js +385 -0
  138. package/dist/view/host.js.map +1 -0
  139. package/dist/view/index.d.ts +12 -0
  140. package/dist/view/index.d.ts.map +1 -0
  141. package/dist/view/index.js +7 -0
  142. package/dist/view/index.js.map +1 -0
  143. package/dist/view/protocol.d.ts +220 -0
  144. package/dist/view/protocol.d.ts.map +1 -0
  145. package/dist/view/protocol.js +246 -0
  146. package/dist/view/protocol.js.map +1 -0
  147. package/dist/view/reference.d.ts +18 -0
  148. package/dist/view/reference.d.ts.map +1 -0
  149. package/dist/view/reference.js +25 -0
  150. package/dist/view/reference.js.map +1 -0
  151. package/dist/view/resources.d.ts +51 -0
  152. package/dist/view/resources.d.ts.map +1 -0
  153. package/dist/view/resources.js +242 -0
  154. package/dist/view/resources.js.map +1 -0
  155. package/dist/view/sanitize.d.ts +32 -0
  156. package/dist/view/sanitize.d.ts.map +1 -0
  157. package/dist/view/sanitize.js +226 -0
  158. package/dist/view/sanitize.js.map +1 -0
  159. package/dist/zip/central.d.ts +12 -0
  160. package/dist/zip/central.d.ts.map +1 -0
  161. package/dist/zip/central.js +72 -0
  162. package/dist/zip/central.js.map +1 -0
  163. package/dist/zip/crc32.d.ts +3 -0
  164. package/dist/zip/crc32.d.ts.map +1 -0
  165. package/dist/zip/crc32.js +28 -0
  166. package/dist/zip/crc32.js.map +1 -0
  167. package/dist/zip/eocd.d.ts +9 -0
  168. package/dist/zip/eocd.d.ts.map +1 -0
  169. package/dist/zip/eocd.js +84 -0
  170. package/dist/zip/eocd.js.map +1 -0
  171. package/dist/zip/errors.d.ts +19 -0
  172. package/dist/zip/errors.d.ts.map +1 -0
  173. package/dist/zip/errors.js +37 -0
  174. package/dist/zip/errors.js.map +1 -0
  175. package/dist/zip/index.d.ts +18 -0
  176. package/dist/zip/index.d.ts.map +1 -0
  177. package/dist/zip/index.js +78 -0
  178. package/dist/zip/index.js.map +1 -0
  179. package/dist/zip/inflate.d.ts +2 -0
  180. package/dist/zip/inflate.d.ts.map +1 -0
  181. package/dist/zip/inflate.js +43 -0
  182. package/dist/zip/inflate.js.map +1 -0
  183. package/dist/zip/source.d.ts +15 -0
  184. package/dist/zip/source.d.ts.map +1 -0
  185. package/dist/zip/source.js +34 -0
  186. package/dist/zip/source.js.map +1 -0
  187. package/package.json +75 -0
  188. package/src/core/book.ts +80 -0
  189. package/src/core/errors.ts +31 -0
  190. package/src/core/format.ts +14 -0
  191. package/src/core/graphemes.ts +73 -0
  192. package/src/core/index.ts +27 -0
  193. package/src/core/lookup.ts +16 -0
  194. package/src/core/open.ts +31 -0
  195. package/src/core/position.ts +485 -0
  196. package/src/core/reading-text.ts +200 -0
  197. package/src/core/source.ts +61 -0
  198. package/src/core/storage.ts +6 -0
  199. package/src/core/text.ts +35 -0
  200. package/src/formats/epub/href.ts +54 -0
  201. package/src/formats/epub/index.ts +235 -0
  202. package/src/formats/epub/opf.ts +143 -0
  203. package/src/formats/epub/toc.ts +120 -0
  204. package/src/formats/fb2/index.ts +348 -0
  205. package/src/formats/index.ts +3 -0
  206. package/src/formats/text/index.ts +208 -0
  207. package/src/formats/xml.ts +326 -0
  208. package/src/index.ts +26 -0
  209. package/src/layout/chunk.ts +169 -0
  210. package/src/layout/index.ts +573 -0
  211. package/src/reader/index.ts +1206 -0
  212. package/src/reader/input.ts +122 -0
  213. package/src/search/extract.ts +403 -0
  214. package/src/search/index.ts +10 -0
  215. package/src/search/matcher.ts +187 -0
  216. package/src/search/normalize.ts +122 -0
  217. package/src/view/allowlist.ts +273 -0
  218. package/src/view/appearance.ts +380 -0
  219. package/src/view/css.ts +172 -0
  220. package/src/view/frame.ts +1018 -0
  221. package/src/view/host.ts +527 -0
  222. package/src/view/index.ts +24 -0
  223. package/src/view/protocol.ts +412 -0
  224. package/src/view/reference.ts +30 -0
  225. package/src/view/resources.ts +265 -0
  226. package/src/view/sanitize.ts +266 -0
  227. package/src/zip/central.ts +85 -0
  228. package/src/zip/crc32.ts +30 -0
  229. package/src/zip/eocd.ts +97 -0
  230. package/src/zip/errors.ts +41 -0
  231. package/src/zip/index.ts +114 -0
  232. package/src/zip/inflate.ts +41 -0
  233. package/src/zip/source.ts +51 -0
@@ -0,0 +1,208 @@
1
+ import type { Book, BookFormat, BookMetadata, ByteSource, Section, TocItem } from '../../core/index.ts';
2
+ import { sectionLookup } from '../../core/lookup.ts';
3
+ import { collapseWhitespace, escapeXmlText } from '../../core/text.ts';
4
+
5
+ /**
6
+ * Plain-text format. The seam probe (PLAN M4-1): TXT synthesizes everything the
7
+ * `Book` model needs — id-addressed sections, a coarse TOC, minimal metadata —
8
+ * from a byte stream that has no container, no manifest and no markup, proving the
9
+ * decoder seam is not EPUB-shaped. Sections are emitted as XHTML (paragraphs and
10
+ * chapter headings) so the existing view renders them untouched; the format holds
11
+ * no `resolve` (plain text references nothing) and no resources.
12
+ */
13
+ export const text: BookFormat = {
14
+ name: 'text',
15
+ async sniff(source) {
16
+ // A conservative last-resort claim: reject anything that is plainly another
17
+ // format (zip/EPUB, PDF, an XML/HTML document like FB2/XHTML) or binary
18
+ // (an unmarked NUL byte), and otherwise accept. Registered after richer
19
+ // formats, so this only sees bytes nothing else claimed.
20
+ const head = await source.read(0, Math.min(source.size, 512));
21
+ if (head.length === 0) return false;
22
+ if (hasBom(head)) return true; // a Unicode BOM is a positive text signal
23
+ // Zip (PK\x03\x04) and PDF (%PDF) are not text.
24
+ if (head[0] === 0x50 && head[1] === 0x4b && head[2] === 0x03 && head[3] === 0x04) return false;
25
+ if (head[0] === 0x25 && head[1] === 0x50 && head[2] === 0x44 && head[3] === 0x46) return false;
26
+ // An XML/HTML document (FB2, XHTML) opens with '<' after optional whitespace.
27
+ const firstGlyph = head.find((b) => b !== 0x20 && b !== 0x09 && b !== 0x0a && b !== 0x0d && b !== 0xef && b !== 0xbb && b !== 0xbf);
28
+ if (firstGlyph === 0x3c) return false;
29
+ // An unmarked NUL byte means binary (or UTF-16 without a BOM we do not guess).
30
+ if (head.includes(0x00)) return false;
31
+ return true;
32
+ },
33
+ async decode(source) {
34
+ const bytes = await source.read(0, source.size);
35
+ const raw = decodeText(bytes);
36
+ const normalized = raw.replace(/\r\n?/g, '\n');
37
+ const metadata = readGutenbergMetadata(normalized);
38
+ const built = buildSections(normalized);
39
+ const toc = buildToc(built);
40
+ const sections = built.map((s) => s.section);
41
+ return {
42
+ metadata,
43
+ toc,
44
+ sections,
45
+ section: sectionLookup(sections),
46
+ resources: new Map(),
47
+ } satisfies Book;
48
+ },
49
+ };
50
+
51
+ // --- encoding ---------------------------------------------------------------
52
+
53
+ function hasBom(head: Uint8Array): boolean {
54
+ return (
55
+ (head[0] === 0xef && head[1] === 0xbb && head[2] === 0xbf) || // UTF-8
56
+ (head[0] === 0xff && head[1] === 0xfe) || // UTF-16LE
57
+ (head[0] === 0xfe && head[1] === 0xff) // UTF-16BE
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Decodes text bytes: a BOM picks the encoding outright; otherwise UTF-8 is tried
63
+ * strictly and windows-1252 is the legacy fallback (the codepage most non-UTF-8
64
+ * English/Western TXT actually uses). The BOM bytes are stripped with the decoder's
65
+ * default BOM handling for UTF-8/16.
66
+ */
67
+ function decodeText(bytes: Uint8Array): string {
68
+ if (bytes[0] === 0xff && bytes[1] === 0xfe) return new TextDecoder('utf-16le').decode(bytes);
69
+ if (bytes[0] === 0xfe && bytes[1] === 0xff) return new TextDecoder('utf-16be').decode(bytes);
70
+ if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
71
+ return new TextDecoder('utf-8').decode(bytes.subarray(3));
72
+ }
73
+ try {
74
+ return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
75
+ } catch {
76
+ return new TextDecoder('windows-1252').decode(bytes);
77
+ }
78
+ }
79
+
80
+ // --- structure --------------------------------------------------------------
81
+
82
+ interface BuiltSection {
83
+ readonly section: Section;
84
+ readonly heading?: string;
85
+ }
86
+
87
+ const HEADING_KEYWORD = /^(chapter|book|part|canto|letter|volume|section|prologue|epilogue|introduction|preface|foreword|afterword)\b/i;
88
+
89
+ /**
90
+ * Coarse Gutenberg-style split: blocks are separated by blank lines; a short block
91
+ * that reads as a chapter heading (a keyword line like "CHAPTER I", or a short
92
+ * all-caps/titley line) starts a new section. When no heading is ever found the
93
+ * whole text becomes one synthetic section, so a headingless file still reads.
94
+ */
95
+ function buildSections(normalized: string): BuiltSection[] {
96
+ const blocks = normalized
97
+ .split(/\n[ \t]*\n+/)
98
+ .map((b) => b.replace(/^\n+|\n+$/g, '').trim())
99
+ .filter((b) => b.length > 0);
100
+
101
+ const sections: BuiltSection[] = [];
102
+ let heading: string | undefined;
103
+ let paragraphs: string[] = [];
104
+ let index = 0;
105
+
106
+ const flush = (): void => {
107
+ if (heading === undefined && paragraphs.length === 0) return;
108
+ const id = `s${index}`;
109
+ index += 1;
110
+ const html = renderXhtml(heading, paragraphs);
111
+ const captured = heading;
112
+ sections.push({
113
+ section: {
114
+ id,
115
+ mediaType: 'application/xhtml+xml',
116
+ load: () => Promise.resolve(new TextEncoder().encode(html)),
117
+ },
118
+ ...(captured === undefined ? {} : { heading: captured }),
119
+ });
120
+ heading = undefined;
121
+ paragraphs = [];
122
+ };
123
+
124
+ for (const block of blocks) {
125
+ if (isHeading(block)) {
126
+ flush();
127
+ heading = collapseWhitespace(block);
128
+ } else {
129
+ paragraphs.push(block);
130
+ }
131
+ }
132
+ flush();
133
+
134
+ if (sections.length === 0) {
135
+ // Empty or whitespace-only input still yields one (empty) readable section.
136
+ sections.push({
137
+ section: {
138
+ id: 's0',
139
+ mediaType: 'application/xhtml+xml',
140
+ load: () => Promise.resolve(new TextEncoder().encode(renderXhtml(undefined, []))),
141
+ },
142
+ });
143
+ }
144
+ return sections;
145
+ }
146
+
147
+ function isHeading(block: string): boolean {
148
+ if (block.includes('\n')) return false; // a heading is a single short line
149
+ const line = block.trim();
150
+ if (line.length === 0 || line.length > 60) return false;
151
+ if (HEADING_KEYWORD.test(line)) return true;
152
+ // A short line in all caps (letters present, no lowercase) reads as a heading.
153
+ return /[A-Z]/.test(line) && !/[a-z]/.test(line) && line.length <= 48;
154
+ }
155
+
156
+ function buildToc(sections: BuiltSection[]): TocItem[] {
157
+ const toc: TocItem[] = [];
158
+ for (const s of sections) {
159
+ if (s.heading === undefined) continue;
160
+ toc.push({ label: s.heading, sectionId: s.section.id, children: [] });
161
+ }
162
+ return toc;
163
+ }
164
+
165
+ // --- metadata ---------------------------------------------------------------
166
+
167
+ /**
168
+ * Best-effort metadata from Project Gutenberg's plain-text header conventions:
169
+ * either the "Title: X / Author: Y" block or the "The Project Gutenberg eBook of
170
+ * TITLE, by AUTHOR" line. Anything not found is omitted, never set to undefined.
171
+ */
172
+ function readGutenbergMetadata(normalized: string): BookMetadata {
173
+ const head = normalized.slice(0, 4000);
174
+ let title: string | undefined;
175
+ let author: string | undefined;
176
+
177
+ const titleField = head.match(/^Title:\s*(.+)$/im);
178
+ if (titleField?.[1] !== undefined) title = collapseWhitespace(titleField[1]);
179
+ const authorField = head.match(/^Author:\s*(.+)$/im);
180
+ if (authorField?.[1] !== undefined) author = collapseWhitespace(authorField[1]);
181
+
182
+ if (title === undefined) {
183
+ const banner = head.match(/Project Gutenberg eBook of\s+(.+?)(?:,\s*by\s+(.+?))?[\r\n]/i);
184
+ if (banner?.[1] !== undefined) title = collapseWhitespace(banner[1]);
185
+ if (author === undefined && banner?.[2] !== undefined) author = collapseWhitespace(banner[2]);
186
+ }
187
+
188
+ return {
189
+ ...(title === undefined ? {} : { title }),
190
+ ...(author === undefined ? {} : { author }),
191
+ };
192
+ }
193
+
194
+ // --- rendering --------------------------------------------------------------
195
+
196
+ function renderXhtml(heading: string | undefined, paragraphs: readonly string[]): string {
197
+ const body: string[] = [];
198
+ if (heading !== undefined) body.push(`<h2>${escapeXmlText(heading)}</h2>`);
199
+ for (const p of paragraphs) {
200
+ // A block's internal newlines are soft line breaks within one paragraph.
201
+ body.push(`<p>${escapeXmlText(collapseParagraph(p))}</p>`);
202
+ }
203
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<html xmlns="http://www.w3.org/1999/xhtml"><head><meta charset="UTF-8"/></head><body>${body.join('')}</body></html>`;
204
+ }
205
+
206
+ function collapseParagraph(s: string): string {
207
+ return s.replace(/\s*\n\s*/g, ' ').replace(/[ \t]+/g, ' ').trim();
208
+ }
@@ -0,0 +1,326 @@
1
+ export interface XmlElement {
2
+ readonly name: string;
3
+ readonly localName: string;
4
+ readonly attributes: ReadonlyMap<string, string>;
5
+ readonly children: readonly XmlElement[];
6
+ /** Child elements and text chunks in document order. */
7
+ readonly content: readonly (XmlElement | string)[];
8
+ /** Concatenated direct text content, entity-decoded, CDATA included. */
9
+ readonly text: string;
10
+ }
11
+
12
+ interface MutableElement {
13
+ name: string;
14
+ localName: string;
15
+ attributes: Map<string, string>;
16
+ children: MutableElement[];
17
+ content: (MutableElement | string)[];
18
+ text: string;
19
+ }
20
+
21
+ export function decodeXml(bytes: Uint8Array): string {
22
+ if (bytes.length >= 2) {
23
+ if (bytes[0] === 0xff && bytes[1] === 0xfe) return new TextDecoder('utf-16le').decode(bytes);
24
+ if (bytes[0] === 0xfe && bytes[1] === 0xff) return new TextDecoder('utf-16be').decode(bytes);
25
+ }
26
+ // A BOM decides outright; otherwise honor the XML declaration's encoding label
27
+ // for non-Unicode codepages — real FB2 is frequently windows-1251, and assuming
28
+ // UTF-8 would mojibake every Cyrillic character. The label is read from the
29
+ // prolog as Latin-1 (one byte → one char) so it is legible before the true
30
+ // encoding is known. An unknown label falls back to UTF-8.
31
+ const label = encodingFromProlog(bytes);
32
+ if (label !== undefined && !/^utf-?8$/i.test(label) && !/^utf-?16/i.test(label)) {
33
+ try {
34
+ return new TextDecoder(label).decode(bytes);
35
+ } catch {
36
+ // Unsupported label — fall through to UTF-8.
37
+ }
38
+ }
39
+ return new TextDecoder().decode(bytes);
40
+ }
41
+
42
+ function encodingFromProlog(bytes: Uint8Array): string | undefined {
43
+ const head = bytes.subarray(0, Math.min(bytes.length, 200));
44
+ let prolog = '';
45
+ for (let i = 0; i < head.length; i += 1) prolog += String.fromCharCode(head[i]!);
46
+ if (!prolog.startsWith('<?xml')) return undefined;
47
+ const end = prolog.indexOf('?>');
48
+ if (end === -1) return undefined;
49
+ const match = prolog.slice(0, end).match(/encoding\s*=\s*["']([^"']+)["']/i);
50
+ return match?.[1];
51
+ }
52
+
53
+ export function localNameOf(name: string): string {
54
+ const colon = name.indexOf(':');
55
+ return colon === -1 ? name : name.slice(colon + 1);
56
+ }
57
+
58
+ /** Attribute by exact name first, then by local name ignoring any prefix. */
59
+ export function attribute(element: XmlElement, name: string): string | undefined {
60
+ const exact = element.attributes.get(name);
61
+ if (exact !== undefined) return exact;
62
+ for (const [key, value] of element.attributes) {
63
+ if (localNameOf(key) === name) return value;
64
+ }
65
+ return undefined;
66
+ }
67
+
68
+ export function childrenNamed(element: XmlElement, localName: string): readonly XmlElement[] {
69
+ return element.children.filter((child) => child.localName === localName);
70
+ }
71
+
72
+ export function firstChildNamed(element: XmlElement, localName: string): XmlElement | undefined {
73
+ return element.children.find((child) => child.localName === localName);
74
+ }
75
+
76
+ /** All text within the element in document order, entity-decoded. */
77
+ export function deepText(element: XmlElement): string {
78
+ let out = '';
79
+ for (const node of element.content) {
80
+ out += typeof node === 'string' ? node : deepText(node);
81
+ }
82
+ return out;
83
+ }
84
+
85
+ export function descendantsNamed(element: XmlElement, localName: string): readonly XmlElement[] {
86
+ const found: XmlElement[] = [];
87
+ const walk = (parent: XmlElement): void => {
88
+ for (const child of parent.children) {
89
+ if (child.localName === localName) found.push(child);
90
+ walk(child);
91
+ }
92
+ };
93
+ walk(element);
94
+ return found;
95
+ }
96
+
97
+ const NAME_END = new Set([' ', '\t', '\n', '\r', '>', '/', '=']);
98
+
99
+ export interface ParseXmlOptions {
100
+ /**
101
+ * Recover from light well-formedness slips instead of throwing: a valueless
102
+ * (HTML-boolean-style) attribute becomes an empty-string attribute, an unquoted
103
+ * attribute value reads to the next whitespace or tag end, a close tag that
104
+ * mismatches the open stack closes the nearest matching ancestor (or is ignored
105
+ * when nothing matches), and elements left open at end of input are auto-closed.
106
+ * Strict is the default; tolerance is opt-in per caller so strict guarantees
107
+ * (EPUB's container/OPF/nav parsing) are unchanged.
108
+ */
109
+ readonly tolerant?: boolean;
110
+ }
111
+
112
+ export function parseXml(input: string, options?: ParseXmlOptions): XmlElement {
113
+ const tolerant = options?.tolerant === true;
114
+ let pos = input.charCodeAt(0) === 0xfeff ? 1 : 0;
115
+ let root: MutableElement | undefined;
116
+ const stack: MutableElement[] = [];
117
+
118
+ const fail: (message: string) => never = (message) => {
119
+ throw new Error(`malformed XML at offset ${pos}: ${message}`);
120
+ };
121
+
122
+ const skipWhitespace = (): void => {
123
+ while (pos < input.length && isWhitespace(input[pos])) pos++;
124
+ };
125
+
126
+ const readName = (): string => {
127
+ const start = pos;
128
+ while (pos < input.length && !NAME_END.has(input[pos] as string)) pos++;
129
+ if (pos === start) fail('expected a name');
130
+ return input.slice(start, pos);
131
+ };
132
+
133
+ const skipPast = (terminator: string, what: string): void => {
134
+ const end = input.indexOf(terminator, pos);
135
+ if (end === -1) fail(`unterminated ${what}`);
136
+ pos = end + terminator.length;
137
+ };
138
+
139
+ const skipMarkupDeclaration = (): void => {
140
+ let depth = 0;
141
+ for (; pos < input.length; pos++) {
142
+ const ch = input[pos];
143
+ if (ch === '[') depth++;
144
+ else if (ch === ']') depth--;
145
+ else if (ch === '>' && depth <= 0) {
146
+ pos++;
147
+ return;
148
+ }
149
+ }
150
+ fail('unterminated markup declaration');
151
+ };
152
+
153
+ const readAttributes = (element: MutableElement): boolean => {
154
+ for (;;) {
155
+ skipWhitespace();
156
+ if (pos >= input.length) {
157
+ if (!tolerant) fail(`unterminated <${element.name}> tag`);
158
+ return false;
159
+ }
160
+ if (input[pos] === '>') {
161
+ pos++;
162
+ return false;
163
+ }
164
+ if (input[pos] === '/') {
165
+ pos++;
166
+ if (input[pos] !== '>') {
167
+ if (!tolerant) fail(`expected "/>" in <${element.name}>`);
168
+ continue; // stray slash inside the tag — drop it
169
+ }
170
+ pos++;
171
+ return true;
172
+ }
173
+ const name = readName();
174
+ skipWhitespace();
175
+ if (input[pos] !== '=') {
176
+ if (!tolerant) fail(`attribute ${name} has no value`);
177
+ // Valueless (HTML-boolean-style) attribute — keep it as an empty string.
178
+ element.attributes.set(name, '');
179
+ continue;
180
+ }
181
+ pos++;
182
+ skipWhitespace();
183
+ const quote = input[pos];
184
+ if (quote !== '"' && quote !== "'") {
185
+ if (!tolerant) fail(`attribute ${name} value is not quoted`);
186
+ // Unquoted value — read to the next whitespace or tag end.
187
+ const start = pos;
188
+ while (pos < input.length && !isWhitespace(input[pos]) && input[pos] !== '>') pos++;
189
+ let raw = input.slice(start, pos);
190
+ if (raw.endsWith('/') && input[pos] === '>') {
191
+ raw = raw.slice(0, -1);
192
+ pos -= 1; // leave "/>" for the self-closing check
193
+ }
194
+ element.attributes.set(name, decodeEntities(raw));
195
+ continue;
196
+ }
197
+ pos++;
198
+ const end = input.indexOf(quote, pos);
199
+ if (end === -1) {
200
+ if (!tolerant) fail(`unterminated value for attribute ${name}`);
201
+ // Unterminated quote — take what is there up to the tag end.
202
+ const gt = input.indexOf('>', pos);
203
+ const stop = gt === -1 ? input.length : gt;
204
+ element.attributes.set(name, decodeEntities(input.slice(pos, stop)));
205
+ pos = stop;
206
+ continue;
207
+ }
208
+ element.attributes.set(name, decodeEntities(input.slice(pos, end)));
209
+ pos = end + 1;
210
+ }
211
+ };
212
+
213
+ while (pos < input.length) {
214
+ const lt = input.indexOf('<', pos);
215
+ if (lt === -1) break;
216
+ if (lt > pos) {
217
+ const parent = stack[stack.length - 1];
218
+ if (parent !== undefined) appendText(parent, decodeEntities(input.slice(pos, lt)));
219
+ }
220
+ pos = lt;
221
+ if (input.startsWith('<?', pos)) {
222
+ pos += 2;
223
+ skipPast('?>', 'processing instruction');
224
+ } else if (input.startsWith('<!--', pos)) {
225
+ pos += 4;
226
+ skipPast('-->', 'comment');
227
+ } else if (input.startsWith('<![CDATA[', pos)) {
228
+ pos += 9;
229
+ const end = input.indexOf(']]>', pos);
230
+ if (end === -1) fail('unterminated CDATA section');
231
+ const parent = stack[stack.length - 1];
232
+ if (parent !== undefined) appendText(parent, input.slice(pos, end));
233
+ pos = end + 3;
234
+ } else if (input.startsWith('<!', pos)) {
235
+ pos += 2;
236
+ skipMarkupDeclaration();
237
+ } else if (input.startsWith('</', pos)) {
238
+ pos += 2;
239
+ const name = readName();
240
+ skipWhitespace();
241
+ if (input[pos] !== '>') {
242
+ if (!tolerant) fail(`unterminated </${name}>`);
243
+ const gt = input.indexOf('>', pos);
244
+ pos = gt === -1 ? input.length : gt + 1;
245
+ } else pos++;
246
+ const open = stack.pop();
247
+ if (open === undefined) {
248
+ // Tolerant: a stray close with nothing open is ignored.
249
+ if (!tolerant) fail(`</${name}> has no matching open tag`);
250
+ } else if (open.name !== name) {
251
+ if (!tolerant) fail(`</${name}> closes <${open.name}>`);
252
+ if (stack.some((el) => el.name === name)) {
253
+ // Close the nearest matching ancestor, auto-closing what it skipped
254
+ // (children were attached at open time, so popping loses nothing).
255
+ while (stack.length > 0 && stack[stack.length - 1]!.name !== name) stack.pop();
256
+ stack.pop();
257
+ } else {
258
+ // No matching open tag anywhere above — ignore the stray close.
259
+ stack.push(open);
260
+ }
261
+ }
262
+ } else {
263
+ pos++;
264
+ const name = readName();
265
+ const element: MutableElement = {
266
+ name,
267
+ localName: localNameOf(name),
268
+ attributes: new Map(),
269
+ children: [],
270
+ content: [],
271
+ text: '',
272
+ };
273
+ const selfClosing = readAttributes(element);
274
+ const parent = stack[stack.length - 1];
275
+ if (parent !== undefined) {
276
+ parent.children.push(element);
277
+ parent.content.push(element);
278
+ } else if (root === undefined) root = element;
279
+ else if (!tolerant) fail(`second root element <${name}>`);
280
+ // Tolerant: trailing junk after the root parses but is discarded.
281
+ if (!selfClosing) stack.push(element);
282
+ }
283
+ }
284
+
285
+ const open = stack[stack.length - 1];
286
+ // Tolerant: elements left open at end of input are auto-closed.
287
+ if (open !== undefined && !tolerant) fail(`unclosed element <${open.name}>`);
288
+ if (root === undefined) fail('no root element');
289
+ return root;
290
+ }
291
+
292
+ function appendText(parent: MutableElement, text: string): void {
293
+ parent.text += text;
294
+ const last = parent.content[parent.content.length - 1];
295
+ if (typeof last === 'string') parent.content[parent.content.length - 1] = last + text;
296
+ else parent.content.push(text);
297
+ }
298
+
299
+ function isWhitespace(ch: string | undefined): boolean {
300
+ return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r';
301
+ }
302
+
303
+ // nbsp is the one HTML entity that shows up in real nav-document labels.
304
+ const NAMED_ENTITIES: Record<string, string> = {
305
+ amp: '&',
306
+ lt: '<',
307
+ gt: '>',
308
+ quot: '"',
309
+ apos: "'",
310
+ nbsp: ' ',
311
+ };
312
+
313
+ function decodeEntities(text: string): string {
314
+ if (!text.includes('&')) return text;
315
+ return text.replace(/&(#x[0-9a-fA-F]+|#[0-9]+|[a-zA-Z][a-zA-Z0-9]*);/g, (match, body: string) => {
316
+ if (body.startsWith('#')) {
317
+ const code = body[1] === 'x' ? Number.parseInt(body.slice(2), 16) : Number.parseInt(body.slice(1), 10);
318
+ try {
319
+ return String.fromCodePoint(code);
320
+ } catch {
321
+ return match;
322
+ }
323
+ }
324
+ return NAMED_ENTITIES[body] ?? match;
325
+ });
326
+ }
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The package root: the browser-facing reader facade plus the headless core it
3
+ * operates on.
4
+ *
5
+ * import { open, render } from 'wolfy-reader';
6
+ * import { epub } from 'wolfy-reader/epub';
7
+ *
8
+ * Core travels with the facade because `render` takes a `Book` and `open` is the
9
+ * only public way to make one — splitting them would make the common case a
10
+ * three-import dance, and the facade's own surface already names `Position`,
11
+ * `TocItem` and `SentenceRange` in its signatures.
12
+ *
13
+ * Both re-exports resolve to the same emitted modules as `wolfy-reader/core`, so
14
+ * there is one `open`, one `Position` model and one set of error constructors
15
+ * however a consumer reaches them — `instanceof BookError` holds across entries.
16
+ *
17
+ * Headless consumers should import `wolfy-reader/core` instead. This root reaches
18
+ * `src/layout` and `src/view`; it is import-safe under Node today and the pack
19
+ * fidelity check keeps it that way, but only `/core` carries the guarantee.
20
+ *
21
+ * Format decoders are deliberately absent: each is its own subpath because their
22
+ * closures differ by an order of magnitude — only `epub` pulls the ZIP reader —
23
+ * and `open(input, { formats })` takes an explicit list by design.
24
+ */
25
+ export * from './core/index.ts';
26
+ export * from './reader/index.ts';