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,73 @@
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
+
15
+ /** A grapheme cluster together with its UTF-16 code-unit offset in the text. */
16
+ export interface Grapheme {
17
+ readonly segment: string;
18
+ readonly index: number;
19
+ }
20
+
21
+ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
22
+
23
+ /** Segments `text` into grapheme clusters, each keeping its code-unit index. */
24
+ export function segmentGraphemes(text: string): Grapheme[] {
25
+ const out: Grapheme[] = [];
26
+ for (const segment of graphemeSegmenter.segment(text)) {
27
+ out.push({ segment: segment.segment, index: segment.index });
28
+ }
29
+ return out;
30
+ }
31
+
32
+ /** Number of grapheme clusters in `text`. */
33
+ export function countGraphemes(text: string): number {
34
+ let count = 0;
35
+ for (const _ of graphemeSegmenter.segment(text)) count += 1;
36
+ return count;
37
+ }
38
+
39
+ /**
40
+ * Maps a UTF-16 offset to the grapheme index at or after it: the first index
41
+ * whose boundary is >= `offset`, or the grapheme count when `offset` is past
42
+ * every boundary. Binary search over the sorted `.index` boundaries.
43
+ */
44
+ export function codeUnitOffsetToGraphemeIndex(
45
+ graphemes: readonly Grapheme[],
46
+ offset: number,
47
+ ): number {
48
+ if (offset <= 0) return 0;
49
+ let low = 0;
50
+ let high = graphemes.length;
51
+ while (low < high) {
52
+ const mid = (low + high) >>> 1;
53
+ if (graphemes[mid]!.index >= offset) high = mid;
54
+ else low = mid + 1;
55
+ }
56
+ return low;
57
+ }
58
+
59
+ /**
60
+ * Maps a grapheme index to its UTF-16 code-unit offset. An index at or past the
61
+ * end maps to `textLength` (the caller supplies it because a boundary array does
62
+ * not know where the final cluster ends). Exact for surrogate pairs, combining
63
+ * sequences, and emoji clusters, because each grapheme carries its real index.
64
+ */
65
+ export function graphemeIndexToCodeUnitOffset(
66
+ graphemes: readonly Grapheme[],
67
+ textLength: number,
68
+ graphemeIndex: number,
69
+ ): number {
70
+ if (graphemeIndex <= 0) return 0;
71
+ if (graphemeIndex >= graphemes.length) return textLength;
72
+ return graphemes[graphemeIndex]!.index;
73
+ }
@@ -0,0 +1,27 @@
1
+ export type { Book, BookMetadata, ReadingDirection, Resource, Section, TocItem } from './book.ts';
2
+ export {
3
+ BookError,
4
+ CorruptContainerError,
5
+ EncryptedContentError,
6
+ UnrecognizedFormatError,
7
+ } from './errors.ts';
8
+ export type { BookFormat, FormatContext } from './format.ts';
9
+ export { open } from './open.ts';
10
+ export {
11
+ capturePosition,
12
+ parsePosition,
13
+ resolvePosition,
14
+ segmentSentences,
15
+ serializePosition,
16
+ } from './position.ts';
17
+ export type {
18
+ CapturePositionOptions,
19
+ Position,
20
+ ResolvedPosition,
21
+ SentenceRange,
22
+ TextAnchor,
23
+ } from './position.ts';
24
+ export type { OpenOptions } from './open.ts';
25
+ export { toByteSource } from './source.ts';
26
+ export type { BookInput, ByteSource, RangeRead, RangeReader } from './source.ts';
27
+ export type { StorageAdapter } from './storage.ts';
@@ -0,0 +1,16 @@
1
+ import type { Section } from './book.ts';
2
+
3
+ /**
4
+ * A `Map`-backed `Book.section(id)` implementation shared by the decoders, so an
5
+ * id lookup is O(1) instead of a linear scan per call. First match wins on a
6
+ * duplicate id, matching the `Array.find` semantics it replaced (section ids are
7
+ * unique by the model's rules; this only pins the behavior if a decoder ever
8
+ * slips). Internal to the library — not re-exported from `src/core/index.ts`.
9
+ */
10
+ export function sectionLookup(sections: readonly Section[]): (id: string) => Section | undefined {
11
+ const byId = new Map<string, Section>();
12
+ for (const section of sections) {
13
+ if (!byId.has(section.id)) byId.set(section.id, section);
14
+ }
15
+ return (id) => byId.get(id);
16
+ }
@@ -0,0 +1,31 @@
1
+ import type { Book } from './book.ts';
2
+ import type { BookFormat, FormatContext } from './format.ts';
3
+ import type { BookInput } from './source.ts';
4
+ import type { StorageAdapter } from './storage.ts';
5
+ import { BookError, CorruptContainerError, UnrecognizedFormatError } from './errors.ts';
6
+ import { toByteSource } from './source.ts';
7
+
8
+ export interface OpenOptions {
9
+ /** Tried in order; the first format whose sniff claims the input decodes it. */
10
+ readonly formats: readonly BookFormat[];
11
+ readonly storage?: StorageAdapter;
12
+ }
13
+
14
+ /** Bytes in, Book out: runs the registered sniffers in order and decodes with the first claimant. */
15
+ export async function open(input: BookInput, options: OpenOptions): Promise<Book> {
16
+ const source = toByteSource(input);
17
+ const context: FormatContext =
18
+ options.storage === undefined ? {} : { storage: options.storage };
19
+ for (const format of options.formats) {
20
+ if (!(await format.sniff(source))) continue;
21
+ try {
22
+ return await format.decode(source, context);
23
+ } catch (error) {
24
+ if (error instanceof BookError) throw error;
25
+ throw new CorruptContainerError(`${format.name} failed to decode the input`, {
26
+ cause: error,
27
+ });
28
+ }
29
+ }
30
+ throw new UnrecognizedFormatError('no registered format recognizes this input');
31
+ }
@@ -0,0 +1,485 @@
1
+ import { CorruptContainerError } from './errors.ts';
2
+ import {
3
+ codeUnitOffsetToGraphemeIndex,
4
+ countGraphemes,
5
+ segmentGraphemes,
6
+ type Grapheme,
7
+ } from './graphemes.ts';
8
+
9
+ /**
10
+ * A content-addressed anchor into a section's text, Hypothesis-style. A match is
11
+ * found by content (the exact quote plus surrounding context), never by offset:
12
+ * `offset` exists only to break ties between identical quotes and is never
13
+ * load-bearing on its own.
14
+ */
15
+ export interface TextAnchor {
16
+ /** The verbatim run of text the position points at. */
17
+ readonly exact: string;
18
+ /** Text immediately before `exact`, bounded length; boundary-snapped. */
19
+ readonly prefix: string;
20
+ /** Text immediately after `exact`, bounded length; boundary-snapped. */
21
+ readonly suffix: string;
22
+ /**
23
+ * Grapheme-cluster offset of `exact` within the section text. A tiebreak only —
24
+ * used to pick among duplicate quotes, never to locate a match alone.
25
+ */
26
+ readonly offset: number;
27
+ }
28
+
29
+ /**
30
+ * A reading position within a book. Its `serialized` form is an opaque string a
31
+ * host persists verbatim and never parses; `progress` rides alongside for host
32
+ * UI (scrollbars, "42%") without decoding the string. Backed by a content anchor
33
+ * so it survives reflow, font-size, and layout changes.
34
+ */
35
+ export interface Position {
36
+ /** The section this position falls in. */
37
+ readonly sectionId: string;
38
+ /** Fraction through the section, 0..1. For host progress UI only. */
39
+ readonly progress: number;
40
+ /** Opaque, versioned, round-trippable interchange string. Hosts store it as-is. */
41
+ readonly serialized: string;
42
+ /** The content anchor. Resolution matches on this, not on `progress`. */
43
+ readonly anchor: TextAnchor;
44
+ }
45
+
46
+ /**
47
+ * Where a `Position` resolved to in a piece of target text. Offsets are grapheme
48
+ * indices into that text, matching `TextAnchor.offset`. A resolution *miss* is not
49
+ * this type — it is `undefined`, returned by `resolvePosition`.
50
+ */
51
+ export interface ResolvedPosition {
52
+ readonly sectionId: string;
53
+ /** Grapheme index where the matched quote starts in the target text. */
54
+ readonly offset: number;
55
+ /** Length of the matched quote in graphemes. */
56
+ readonly length: number;
57
+ }
58
+
59
+ export interface CapturePositionOptions {
60
+ /**
61
+ * Locale for `Intl.Segmenter` word snapping. Defaults to the runtime default
62
+ * locale. Grapheme snapping is locale-independent.
63
+ */
64
+ readonly locale?: string;
65
+ /** Maximum length (in graphemes) of the captured quote. */
66
+ readonly quoteLength?: number;
67
+ /** Maximum length (in graphemes) of each context window. */
68
+ readonly contextLength?: number;
69
+ }
70
+
71
+ const DEFAULT_QUOTE_LENGTH = 32;
72
+ const DEFAULT_CONTEXT_LENGTH = 32;
73
+
74
+ /** Version tag that leads every serialized position. Version-first, non-negotiable. */
75
+ const SERIAL_PREFIX = 'wr1:';
76
+ const SERIAL_VERSION = 1;
77
+
78
+ interface SerialPayload {
79
+ readonly v: number;
80
+ readonly s: string;
81
+ readonly p: number;
82
+ readonly e: string;
83
+ readonly pre: string;
84
+ readonly suf: string;
85
+ readonly o: number;
86
+ }
87
+
88
+ /**
89
+ * Captures a `Position` at `offset` in a section's plain text. Core is headless:
90
+ * the caller supplies already-extracted text and a UTF-16 code-unit offset into
91
+ * it; no DOM, HTML, or Range is touched here. The offset is snapped to grapheme
92
+ * and word boundaries with `Intl.Segmenter` so an anchor never splits a surrogate
93
+ * pair, combining sequence, or emoji cluster.
94
+ */
95
+ export function capturePosition(
96
+ text: string,
97
+ offset: number,
98
+ sectionId: string,
99
+ options: CapturePositionOptions = {},
100
+ ): Position {
101
+ return captureSegmented(segmentText(text), offset, sectionId, options);
102
+ }
103
+
104
+ /**
105
+ * A text segmented once, shared across many captures — the batch-capture
106
+ * invariant: graphemes and word starts are computed O(1) times per text, never
107
+ * per sentence or per occurrence. `wordStarts` is built lazily on the first
108
+ * word-snap (a capture at index 0 or end-of-text never needs it) and holds the
109
+ * sorted code-unit start offset of every word segment.
110
+ */
111
+ interface SegmentedText {
112
+ readonly text: string;
113
+ readonly graphemes: readonly Grapheme[];
114
+ wordStarts: number[] | null;
115
+ }
116
+
117
+ function segmentText(text: string): SegmentedText {
118
+ return { text, graphemes: segmentGraphemes(text), wordStarts: null };
119
+ }
120
+
121
+ /** `capturePosition` over an already-segmented text; behavior is identical. */
122
+ function captureSegmented(
123
+ segmented: SegmentedText,
124
+ offset: number,
125
+ sectionId: string,
126
+ options: CapturePositionOptions,
127
+ ): Position {
128
+ const { graphemes } = segmented;
129
+ const total = graphemes.length;
130
+
131
+ const rawIndex = codeUnitOffsetToGraphemeIndex(graphemes, offset);
132
+ const start = snapToWordStart(segmented, rawIndex, options.locale);
133
+
134
+ const quoteLength = options.quoteLength ?? DEFAULT_QUOTE_LENGTH;
135
+ const contextLength = options.contextLength ?? DEFAULT_CONTEXT_LENGTH;
136
+
137
+ const quoteEnd = Math.min(start + quoteLength, total);
138
+ const prefixStart = Math.max(start - contextLength, 0);
139
+ const suffixEnd = Math.min(quoteEnd + contextLength, total);
140
+
141
+ const anchor: TextAnchor = {
142
+ exact: join(graphemes, start, quoteEnd),
143
+ prefix: join(graphemes, prefixStart, start),
144
+ suffix: join(graphemes, quoteEnd, suffixEnd),
145
+ offset: start,
146
+ };
147
+
148
+ const progress = total === 0 ? 0 : start / total;
149
+
150
+ return {
151
+ sectionId,
152
+ progress,
153
+ anchor,
154
+ serialized: serializeParts(sectionId, progress, anchor),
155
+ };
156
+ }
157
+
158
+ /** Serializes a position to its opaque, versioned interchange string. */
159
+ export function serializePosition(position: Position): string {
160
+ return serializeParts(position.sectionId, position.progress, position.anchor);
161
+ }
162
+
163
+ /** One sentence of a section, with a `Position` anchored over its whole span. */
164
+ export interface SentenceRange {
165
+ /** The sentence text, trailing whitespace trimmed. */
166
+ readonly text: string;
167
+ /**
168
+ * A `Position` whose anchored quote is the whole sentence, so `resolvePosition`
169
+ * returns its full span — a host can `goTo` it (jump) and `decorate` it
170
+ * (highlight the entire sentence, not just its start).
171
+ */
172
+ readonly position: Position;
173
+ }
174
+
175
+ /**
176
+ * Segments a section's plain text into sentences with `Intl.Segmenter`, each
177
+ * carrying a `Position` anchored over the whole sentence. This is the TTS *enabler*
178
+ * (PLAN M3-6): the library exposes sentence ranges and reuses `decorate` as the
179
+ * highlight primitive so a host can build text-to-speech on top — it speaks nothing
180
+ * and stores nothing. Whitespace-only segments are dropped. Headless: the caller
181
+ * supplies already-extracted text, exactly like `capturePosition`.
182
+ *
183
+ * `Intl.Segmenter` follows UAX #29, which ends a "sentence" at every hard line
184
+ * break — but the reading text is section `textContent`, whose source newlines the
185
+ * rendered page collapses via `white-space:normal`. Segmenting it verbatim would
186
+ * split one visual sentence at every wrapped source line (a heading or short line
187
+ * becoming a two-word "sentence"). So adjacent Intl segments are merged until the
188
+ * run actually ends on sentence-terminal punctuation; the anchor still spans the
189
+ * raw text (newlines included) so it content-matches the frame's section text,
190
+ * while the returned `text` collapses whitespace runs to single spaces for a clean
191
+ * TTS string. Abbreviations (`J. R. R.`) still split — that break is Intl's, and a
192
+ * period genuinely can end a sentence, so we do not second-guess it.
193
+ */
194
+ export function segmentSentences(
195
+ text: string,
196
+ sectionId: string,
197
+ options: CapturePositionOptions = {},
198
+ ): SentenceRange[] {
199
+ const out: SentenceRange[] = [];
200
+ // Segment graphemes (and, lazily, words) once for the whole text; every
201
+ // sentence capture reuses the same arrays instead of re-segmenting — this is
202
+ // what keeps a long chapter linear instead of quadratic.
203
+ const segmented = segmentText(text);
204
+
205
+ // Accumulate a run of Intl segments and its code-unit start until the run ends on
206
+ // sentence-terminal punctuation, then anchor over the raw span. A run start of -1
207
+ // means no run is open (all leading/inter-sentence whitespace is skipped).
208
+ let runStart = -1;
209
+ let runText = '';
210
+ const flush = (): void => {
211
+ const trimmed = runText.replace(/\s+$/u, '');
212
+ if (trimmed.trim().length !== 0) {
213
+ const quoteLength = countGraphemes(trimmed);
214
+ // Anchor over the whole (raw, newline-bearing) run so decorate highlights all
215
+ // of it against the frame's section text.
216
+ const position = captureSegmented(segmented, runStart, sectionId, {
217
+ ...options,
218
+ quoteLength,
219
+ });
220
+ out.push({ text: collapseWhitespace(trimmed), position });
221
+ }
222
+ runStart = -1;
223
+ runText = '';
224
+ };
225
+
226
+ for (const segment of sentenceSegmenter(options.locale).segment(text)) {
227
+ if (segment.segment.trim().length === 0) {
228
+ // Whitespace-only Intl segment: part of an open run, else inter-sentence space.
229
+ if (runStart >= 0) runText += segment.segment;
230
+ continue;
231
+ }
232
+ if (runStart < 0) runStart = segment.index;
233
+ runText += segment.segment;
234
+ if (endsOnSentenceTerminal(runText)) flush();
235
+ }
236
+ if (runStart >= 0) flush();
237
+ return out;
238
+ }
239
+
240
+ /** Sentence-final punctuation, incl. CJK and other-script terminators. */
241
+ const SENTENCE_TERMINAL = /[.!?…。!?؟।]/u;
242
+ /** Trailing closing punctuation a terminator may hide behind (quotes, brackets). */
243
+ const TRAILING_CLOSERS = /[)\]}"'”’»›]+$/u;
244
+
245
+ /**
246
+ * Whether a run, ignoring trailing whitespace and closing punctuation, ends on a
247
+ * sentence terminator — the seam that decides a real sentence break from a mere
248
+ * line wrap. `"the lazy dog."` and `"he said 'go.'"` end a sentence; `"The quick
249
+ * brown\n"` (a wrapped line) does not.
250
+ */
251
+ function endsOnSentenceTerminal(run: string): boolean {
252
+ const core = run.replace(/\s+$/u, '').replace(TRAILING_CLOSERS, '');
253
+ const chars = [...core];
254
+ const last = chars[chars.length - 1];
255
+ return last !== undefined && SENTENCE_TERMINAL.test(last);
256
+ }
257
+
258
+ /** Collapses every run of whitespace to a single space and trims the ends. */
259
+ function collapseWhitespace(text: string): string {
260
+ return text.replace(/\s+/gu, ' ').trim();
261
+ }
262
+
263
+ /**
264
+ * Parses a serialized position back into a `Position`. Round-trips losslessly
265
+ * with `serializePosition`. Throws `CorruptContainerError` when the string is
266
+ * malformed or carries an unknown version — a version this reader cannot honor is
267
+ * a structural failure the host catches by class, distinct from a resolution miss
268
+ * (which the resolver returns as a value, never an error).
269
+ */
270
+ export function parsePosition(serialized: string): Position {
271
+ if (!serialized.startsWith(SERIAL_PREFIX)) {
272
+ throw new CorruptContainerError(
273
+ `not a wolfy-reader position: missing '${SERIAL_PREFIX}' version prefix`,
274
+ );
275
+ }
276
+
277
+ let payload: unknown;
278
+ try {
279
+ payload = JSON.parse(serialized.slice(SERIAL_PREFIX.length));
280
+ } catch (cause) {
281
+ throw new CorruptContainerError('position payload is not valid JSON', { cause });
282
+ }
283
+
284
+ if (!isSerialPayload(payload)) {
285
+ throw new CorruptContainerError('position payload is missing required fields');
286
+ }
287
+ if (payload.v !== SERIAL_VERSION) {
288
+ throw new CorruptContainerError(
289
+ `unsupported position version ${payload.v}; this reader understands version ${SERIAL_VERSION}`,
290
+ );
291
+ }
292
+
293
+ const anchor: TextAnchor = {
294
+ exact: payload.e,
295
+ prefix: payload.pre,
296
+ suffix: payload.suf,
297
+ offset: payload.o,
298
+ };
299
+ return { sectionId: payload.s, progress: payload.p, anchor, serialized };
300
+ }
301
+
302
+ /**
303
+ * Resolves a `Position` against a piece of section text — which may have changed
304
+ * since capture — returning where its anchor now lands, or `undefined` when the
305
+ * quote is gone. A miss is a value, never a throw: matching live content is an
306
+ * expected outcome the caller degrades on (skip the restore, drop the decoration),
307
+ * mirroring `Section.resolve()` returning `undefined`.
308
+ *
309
+ * The match is by content. Every occurrence of the exact quote is found, then
310
+ * scored by how much of the stored prefix/suffix context still surrounds it; the
311
+ * best-scoring occurrence wins. The stored offset breaks a tie only when context
312
+ * cannot — it never selects a location the content contradicts.
313
+ */
314
+ export function resolvePosition(
315
+ position: Position,
316
+ text: string,
317
+ ): ResolvedPosition | undefined {
318
+ const { anchor, sectionId } = position;
319
+ const graphemes = segmentGraphemes(text);
320
+
321
+ if (anchor.exact === '') {
322
+ const offset = Math.min(anchor.offset, graphemes.length);
323
+ return { sectionId, offset, length: 0 };
324
+ }
325
+
326
+ const occurrences = findGraphemeOccurrences(graphemes, anchor.exact);
327
+ if (occurrences.length === 0) return undefined;
328
+
329
+ const quoteLength = segmentGraphemes(anchor.exact).length;
330
+ // Segment the stored context once, outside the occurrence loop.
331
+ const prefix = segmentGraphemes(anchor.prefix).map((g) => g.segment);
332
+ const suffix = segmentGraphemes(anchor.suffix).map((g) => g.segment);
333
+ let best: { offset: number; score: number } | undefined;
334
+ for (const offset of occurrences) {
335
+ const score = contextScore(graphemes, offset, quoteLength, prefix, suffix);
336
+ if (
337
+ best === undefined ||
338
+ score > best.score ||
339
+ (score === best.score &&
340
+ Math.abs(offset - anchor.offset) < Math.abs(best.offset - anchor.offset))
341
+ ) {
342
+ best = { offset, score };
343
+ }
344
+ }
345
+
346
+ return { sectionId, offset: best!.offset, length: quoteLength };
347
+ }
348
+
349
+ /** Grapheme start indices at which `quote` occurs in `graphemes`. */
350
+ function findGraphemeOccurrences(graphemes: Grapheme[], quote: string): number[] {
351
+ const quoteGraphemes = segmentGraphemes(quote).map((g) => g.segment);
352
+ const hits: number[] = [];
353
+ if (quoteGraphemes.length === 0) return hits;
354
+ const last = graphemes.length - quoteGraphemes.length;
355
+ for (let start = 0; start <= last; start += 1) {
356
+ let matched = true;
357
+ for (let offset = 0; offset < quoteGraphemes.length; offset += 1) {
358
+ if (graphemes[start + offset]!.segment !== quoteGraphemes[offset]) {
359
+ matched = false;
360
+ break;
361
+ }
362
+ }
363
+ if (matched) hits.push(start);
364
+ }
365
+ return hits;
366
+ }
367
+
368
+ /**
369
+ * Count of trailing prefix graphemes + leading suffix graphemes that still match.
370
+ * `prefix`/`suffix` are the anchor's context pre-segmented into grapheme strings —
371
+ * segmented once by the caller, never per occurrence.
372
+ */
373
+ function contextScore(
374
+ graphemes: readonly Grapheme[],
375
+ quoteStart: number,
376
+ quoteLength: number,
377
+ prefix: readonly string[],
378
+ suffix: readonly string[],
379
+ ): number {
380
+ let score = 0;
381
+ for (let back = 1; back <= prefix.length; back += 1) {
382
+ const here = graphemes[quoteStart - back];
383
+ const want = prefix[prefix.length - back];
384
+ if (here === undefined || here.segment !== want) break;
385
+ score += 1;
386
+ }
387
+ const afterStart = quoteStart + quoteLength;
388
+ for (let ahead = 0; ahead < suffix.length; ahead += 1) {
389
+ const here = graphemes[afterStart + ahead];
390
+ if (here === undefined || here.segment !== suffix[ahead]) break;
391
+ score += 1;
392
+ }
393
+ return score;
394
+ }
395
+
396
+ function serializeParts(sectionId: string, progress: number, anchor: TextAnchor): string {
397
+ const payload: SerialPayload = {
398
+ v: SERIAL_VERSION,
399
+ s: sectionId,
400
+ p: progress,
401
+ e: anchor.exact,
402
+ pre: anchor.prefix,
403
+ suf: anchor.suffix,
404
+ o: anchor.offset,
405
+ };
406
+ return SERIAL_PREFIX + JSON.stringify(payload);
407
+ }
408
+
409
+ function isSerialPayload(value: unknown): value is SerialPayload {
410
+ if (typeof value !== 'object' || value === null) return false;
411
+ const record = value as Record<string, unknown>;
412
+ return (
413
+ typeof record.v === 'number' &&
414
+ typeof record.s === 'string' &&
415
+ typeof record.p === 'number' &&
416
+ typeof record.e === 'string' &&
417
+ typeof record.pre === 'string' &&
418
+ typeof record.suf === 'string' &&
419
+ typeof record.o === 'number'
420
+ );
421
+ }
422
+
423
+ const wordSegmenters = new Map<string, Intl.Segmenter>();
424
+ const sentenceSegmenters = new Map<string, Intl.Segmenter>();
425
+
426
+ function sentenceSegmenter(locale: string | undefined): Intl.Segmenter {
427
+ const key = locale ?? '';
428
+ let segmenter = sentenceSegmenters.get(key);
429
+ if (segmenter === undefined) {
430
+ segmenter = new Intl.Segmenter(locale, { granularity: 'sentence' });
431
+ sentenceSegmenters.set(key, segmenter);
432
+ }
433
+ return segmenter;
434
+ }
435
+
436
+ function wordSegmenter(locale: string | undefined): Intl.Segmenter {
437
+ const key = locale ?? '';
438
+ let segmenter = wordSegmenters.get(key);
439
+ if (segmenter === undefined) {
440
+ segmenter = new Intl.Segmenter(locale, { granularity: 'word' });
441
+ wordSegmenters.set(key, segmenter);
442
+ }
443
+ return segmenter;
444
+ }
445
+
446
+ function join(graphemes: readonly Grapheme[], start: number, end: number): string {
447
+ let out = '';
448
+ for (let index = start; index < end; index += 1) out += graphemes[index]!.segment;
449
+ return out;
450
+ }
451
+
452
+ /**
453
+ * Snaps a grapheme index to the start of the word segment it falls in, so a
454
+ * captured quote begins on a word boundary rather than mid-token. Word segments
455
+ * tile the text, so the containing segment is the one with the greatest start
456
+ * offset <= the grapheme's offset — a binary search over the word-start array,
457
+ * which is built once per `SegmentedText` and reused by every capture.
458
+ */
459
+ function snapToWordStart(
460
+ segmented: SegmentedText,
461
+ index: number,
462
+ locale: string | undefined,
463
+ ): number {
464
+ const { graphemes } = segmented;
465
+ if (index <= 0 || index >= graphemes.length) return index;
466
+
467
+ if (segmented.wordStarts === null) {
468
+ const starts: number[] = [];
469
+ for (const word of wordSegmenter(locale).segment(segmented.text)) starts.push(word.index);
470
+ segmented.wordStarts = starts;
471
+ }
472
+ const starts = segmented.wordStarts;
473
+
474
+ const unitOffset = graphemes[index]!.index;
475
+ // Greatest word start <= unitOffset. starts[0] is 0 and unitOffset > 0 here,
476
+ // so the search never underflows.
477
+ let low = 0;
478
+ let high = starts.length - 1;
479
+ while (low < high) {
480
+ const mid = (low + high + 1) >>> 1;
481
+ if (starts[mid]! <= unitOffset) low = mid;
482
+ else high = mid - 1;
483
+ }
484
+ return codeUnitOffsetToGraphemeIndex(graphemes, starts[low]!);
485
+ }