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,573 @@
1
+ /**
2
+ * The paginator engine: wraps a {@link ContentHost} over a container element and
3
+ * exposes page-oriented navigation, page↔`Position` mapping, and progress on top
4
+ * of the frame's chunked layout. This is the engine the reader facade (M2-4)
5
+ * consumes; it owns no host vocabulary and never fetches or persists.
6
+ *
7
+ * Layout, measurement, chunking, and eviction all live in the frame (Slice 1 —
8
+ * `ContentHost`); this module orchestrates them and bridges page geometry to the
9
+ * headless `Position` model in `src/core`. Core is *called*, never changed: a page
10
+ * maps to a `Position` through `offsetOfPage` → `capturePosition`, and a
11
+ * `Position` maps back through `resolvePosition` → `pageOfOffset`.
12
+ */
13
+ import { capturePosition, resolvePosition, type Position, type Section } from '../core/index.ts';
14
+ import {
15
+ countGraphemes,
16
+ graphemeIndexToCodeUnitOffset as graphemeIndexToOffset,
17
+ segmentGraphemes,
18
+ type Grapheme,
19
+ } from '../core/graphemes.ts';
20
+ import { DEFAULT_CHUNK_CHARS } from './chunk.ts';
21
+ import { ContentHost, type ContentHostOptions } from '../view/host.ts';
22
+ import type { LayoutMode, PaginateOptions, PaginationState } from '../view/protocol.ts';
23
+
24
+ export type { LayoutMode, PaginateOptions, PaginationState } from '../view/protocol.ts';
25
+
26
+ /** How many chunks either side of the active one stay realized (eviction window). */
27
+ const DEFAULT_WINDOW_CHUNKS = 2;
28
+ /** Gap between text columns, in CSS px. */
29
+ const DEFAULT_COLUMN_GAP = 40;
30
+ /** Text columns per page. */
31
+ const DEFAULT_COLUMN_COUNT = 1;
32
+
33
+ /**
34
+ * The parts of {@link PaginateOptions} a caller may set without also supplying
35
+ * the viewport geometry, which the paginator measures from its container. Every
36
+ * field is optional; unset fields fall back to the paginator defaults.
37
+ */
38
+ export interface PaginateRequest {
39
+ readonly mode?: LayoutMode;
40
+ /** Page box width in CSS px. Defaults to the container's client width. */
41
+ readonly pageWidth?: number;
42
+ /** Page box height in CSS px. Defaults to the container's client height. */
43
+ readonly pageHeight?: number;
44
+ readonly columnGap?: number;
45
+ readonly chunkChars?: number;
46
+ readonly windowChunks?: number;
47
+ /** Text columns per page (1 or 2). Defaults to 1. */
48
+ readonly columnCount?: number;
49
+ }
50
+
51
+ /**
52
+ * Chapter-local layout state: the current page, the total (estimate or firm),
53
+ * and whether the total has been measured across every chunk yet.
54
+ */
55
+ export interface ChapterProgress {
56
+ readonly mode: LayoutMode;
57
+ /** 0-based page currently shown. */
58
+ readonly page: number;
59
+ /** Total pages — an estimate until {@link firm}. */
60
+ readonly totalPages: number;
61
+ /** True once every chunk is measured; false while {@link totalPages} churns. */
62
+ readonly firm: boolean;
63
+ /** Fraction through the chapter, 0..1. */
64
+ readonly fraction: number;
65
+ readonly realizedChunks: number;
66
+ readonly totalChunks: number;
67
+ }
68
+
69
+ /**
70
+ * Where the reader is in the whole book. Section counts come from the facade;
71
+ * only the current section is laid out, so any span across other sections is an
72
+ * even-weight approximation — {@link approximate} says so.
73
+ */
74
+ export interface BookProgress extends ChapterProgress {
75
+ /** 0-based index of the current section in reading order. */
76
+ readonly sectionIndex: number;
77
+ /** Total sections in the book. */
78
+ readonly sectionCount: number;
79
+ /** Fraction through the book, 0..1 — even-weight across sections. */
80
+ readonly bookFraction: number;
81
+ /** Always true: sibling sections are unlaid, so the book span is an estimate. */
82
+ readonly approximate: true;
83
+ }
84
+
85
+ export interface PaginatorDiagnostics {
86
+ readonly domNodes: number;
87
+ readonly realizedChunks: number;
88
+ readonly totalChunks: number;
89
+ }
90
+
91
+ /** Something went wrong driving the paginator; never a book decoding failure. */
92
+ export class PaginatorError extends Error {
93
+ constructor(message: string, options?: ErrorOptions) {
94
+ super(message, options);
95
+ this.name = 'PaginatorError';
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Drives chunked pagination of one section at a time inside a sandboxed frame.
101
+ *
102
+ * Lifecycle: construct over a container, {@link paginate} a section, navigate
103
+ * with {@link goToPage}/{@link nextPage}/{@link previousPage}, map pages to
104
+ * `Position`s, and {@link destroy} to tear the frame down. A single instance
105
+ * paginates successive sections in turn; re-paginating replaces the active one.
106
+ */
107
+ export class Paginator {
108
+ readonly #host: ContentHost;
109
+ readonly #container: HTMLElement;
110
+ #section: Section | null = null;
111
+ #options: PaginateOptions | null = null;
112
+ #state: PaginationState | null = null;
113
+ #page = 0;
114
+ /** Cached section text, invalidated on each {@link paginate}. */
115
+ #text: string | null = null;
116
+ /**
117
+ * Live decoration intent, held for the paginator lifetime so a decoration can be
118
+ * re-anchored and re-drawn after a re-layout (font-size, mode switch, appearance).
119
+ * Draw-only: this is the sole decoration state the engine keeps, and it never
120
+ * leaves memory — nothing is persisted, serialized, or fetched. Keyed by the
121
+ * caller's id; the `className` rides through to the overlay boxes.
122
+ */
123
+ readonly #decorations = new Map<string, { position: Position; className: string }>();
124
+
125
+ constructor(container: HTMLElement, options: ContentHostOptions = {}) {
126
+ this.#container = container;
127
+ this.#host = new ContentHost(container, options);
128
+ }
129
+
130
+ /** The underlying host, for callers that need the raw frame surface. */
131
+ get host(): ContentHost {
132
+ return this.#host;
133
+ }
134
+
135
+ /** The section currently laid out, or `null` before the first {@link paginate}. */
136
+ get section(): Section | null {
137
+ return this.#section;
138
+ }
139
+
140
+ /** The effective options of the current layout, or `null` before {@link paginate}. */
141
+ get options(): PaginateOptions | null {
142
+ return this.#options;
143
+ }
144
+
145
+ /** The last pagination state, or `null` before {@link paginate}. */
146
+ get state(): PaginationState | null {
147
+ return this.#state;
148
+ }
149
+
150
+ /** The current 0-based page. */
151
+ get page(): number {
152
+ return this.#page;
153
+ }
154
+
155
+ /**
156
+ * Chunks and lays out `section` under `request` (merged with the paginator
157
+ * defaults and the container's measured viewport), resets to page 0, and
158
+ * returns the resulting state. The page count starts as an estimate; call
159
+ * {@link refine} to firm it up. Pass `mode: 'scrolled'` for scrolled layout.
160
+ */
161
+ async paginate(section: Section, request: PaginateRequest = {}): Promise<PaginationState> {
162
+ const options = this.#resolveOptions(request);
163
+ // The text cache is keyed by section: a re-paginate of the same section (a
164
+ // mode switch, an appearance reflow) re-lays out the same text, so the cache
165
+ // survives; a different section invalidates it.
166
+ const sameSection = this.#section === section;
167
+ const state = await this.#host.paginate(section, options);
168
+ this.#section = section;
169
+ this.#options = options;
170
+ this.#state = state;
171
+ this.#page = 0;
172
+ if (!sameSection) this.#text = null;
173
+ await this.#redrawDecorations();
174
+ return state;
175
+ }
176
+
177
+ /**
178
+ * Re-lays the current section (after a viewport or typography change) and
179
+ * returns the fresh state. The caller preserving position across the relayout
180
+ * should {@link positionOfPage} before and {@link pageOfPosition} after. Text
181
+ * is unchanged, so its cache survives.
182
+ */
183
+ async relayout(): Promise<PaginationState> {
184
+ this.#requireActive();
185
+ const state = await this.#host.relayout();
186
+ this.#state = state;
187
+ this.#page = clamp(this.#page, 0, Math.max(0, state.pageCount - 1));
188
+ return state;
189
+ }
190
+
191
+ /**
192
+ * Re-lays the current section in a different mode, preserving the reading
193
+ * position across the switch: it captures a `Position` for the current page,
194
+ * re-paginates, then seeks back to the page that `Position` now resolves to.
195
+ * Returns the new state. A resolution miss (rare — same text, same session)
196
+ * degrades to page 0.
197
+ */
198
+ async switchMode(mode: LayoutMode): Promise<PaginationState> {
199
+ this.#requireActive();
200
+ if (this.#options!.mode === mode) return this.relayout();
201
+ const anchor = await this.positionOfPage(this.#page);
202
+ const state = await this.paginate(this.#section!, { ...this.#requestFromOptions(), mode });
203
+ const page = await this.pageOfPosition(anchor);
204
+ if (page >= 0) await this.goToPage(page);
205
+ return state;
206
+ }
207
+
208
+ /**
209
+ * Re-paginates the current section at the container's *current* size, preserving
210
+ * the reading place. Page geometry is captured once at `paginate` time from the
211
+ * container, so a container that later resizes leaves the frame's content at the
212
+ * old size — it scrolls (shrunk) or leaves a gap (grown); this re-reads the size
213
+ * and re-flows. A no-op (returns `null`) when the size is unchanged or the
214
+ * container is unmeasurable (0×0, e.g. `display:none`), so a `ResizeObserver` may
215
+ * call it on every notification without churn. Mirrors {@link switchMode}: capture
216
+ * a `Position`, re-paginate, seek back (a resolution miss degrades to page 0).
217
+ */
218
+ async resize(): Promise<PaginationState | null> {
219
+ this.#requireActive();
220
+ const pageWidth = this.#container.clientWidth;
221
+ const pageHeight = this.#container.clientHeight;
222
+ if (pageWidth === 0 || pageHeight === 0) return null;
223
+ if (this.#options!.pageWidth === pageWidth && this.#options!.pageHeight === pageHeight) return null;
224
+ const anchor = await this.positionOfPage(this.#page);
225
+ const state = await this.paginate(this.#section!, {
226
+ ...this.#requestFromOptions(),
227
+ pageWidth,
228
+ pageHeight,
229
+ });
230
+ const page = await this.pageOfPosition(anchor);
231
+ if (page >= 0) await this.goToPage(page);
232
+ return state;
233
+ }
234
+
235
+ /**
236
+ * Drives the estimate toward the true count by re-measuring. In paginated mode
237
+ * the frame refines the count as chunks realize; a `relayout` forces that
238
+ * measurement. Returns the (possibly firm) state. Idempotent once firm.
239
+ */
240
+ async refine(): Promise<PaginationState> {
241
+ this.#requireActive();
242
+ if (this.#state!.firm) return this.#state!;
243
+ return this.relayout();
244
+ }
245
+
246
+ /** Seeks to a 0-based page, clamping to range. Returns the page actually shown. */
247
+ async goToPage(page: number): Promise<number> {
248
+ this.#requireActive();
249
+ const shown = await this.#host.goToPage(page);
250
+ this.#page = shown;
251
+ return shown;
252
+ }
253
+
254
+ /** Turns one page forward, clamped at the last page. Returns the new page. */
255
+ async nextPage(): Promise<number> {
256
+ return this.goToPage(this.#page + 1);
257
+ }
258
+
259
+ /** Turns one page back, clamped at page 0. Returns the new page. */
260
+ async previousPage(): Promise<number> {
261
+ return this.goToPage(this.#page - 1);
262
+ }
263
+
264
+ /**
265
+ * The `Position` anchored at the start of `page`: page → character offset via
266
+ * `offsetOfPage`, then `capturePosition(text, offset, sectionId)` over the
267
+ * section text. An empty page (offset -1) captures at offset 0.
268
+ */
269
+ async positionOfPage(page: number): Promise<Position> {
270
+ this.#requireActive();
271
+ const text = await this.#sectionText();
272
+ const offset = await this.#host.offsetOfPage(page);
273
+ return capturePosition(text, Math.max(0, offset), this.#section!.id);
274
+ }
275
+
276
+ /**
277
+ * The `Position` anchored over a frame-supplied UTF-16 offset range on the
278
+ * section text — the bridge behind selection reporting. The frame computes the
279
+ * range against the same tiled section text this measures against, so the
280
+ * `start` offset is captured directly with `capturePosition` (which takes a
281
+ * UTF-16 offset). The quote spans the whole selection (`start`..`end`), not a
282
+ * fixed default, so a host that highlights the returned Position via `decorate`
283
+ * paints the entire selection rather than only its first ~32 graphemes; resolving
284
+ * it back to a page still keys off the anchor's start.
285
+ */
286
+ async positionOfOffsetRange(start: number, end: number): Promise<Position> {
287
+ this.#requireActive();
288
+ const text = await this.#sectionText();
289
+ const from = Math.max(0, start);
290
+ const to = Math.max(from, end);
291
+ return capturePosition(text, from, this.#section!.id, {
292
+ quoteLength: countGraphemes(text.slice(from, to)),
293
+ });
294
+ }
295
+
296
+ /**
297
+ * The 0-based page painting `position`: `resolvePosition` against the section
298
+ * text yields a character offset, then `pageOfOffset` maps it to a page.
299
+ * Returns -1 when the anchor no longer resolves (the caller degrades softly).
300
+ *
301
+ * Note the offset spaces differ: `resolvePosition` returns a *grapheme* index,
302
+ * while `pageOfOffset` speaks UTF-16 code units. They coincide for BMP text and
303
+ * for a text whose graphemes are all single code units; the conversion below
304
+ * maps grapheme index back to a code-unit offset so multi-unit clusters map
305
+ * correctly.
306
+ */
307
+ async pageOfPosition(position: Position): Promise<number> {
308
+ this.#requireActive();
309
+ const text = await this.#sectionText();
310
+ const resolved = resolvePosition(position, text);
311
+ if (resolved === undefined) return -1;
312
+ const offset = graphemeIndexToCodeUnitOffset(text, resolved.offset);
313
+ return this.#host.pageOfOffset(offset);
314
+ }
315
+
316
+ /**
317
+ * The 0-based page painting the element carrying `elementId`, or -1 when no
318
+ * element in the section carries that id (the caller degrades softly). Bridges
319
+ * fragment anchoring: `offsetOfElementId` in the frame yields the element's
320
+ * character offset, then `pageOfOffset` maps it to a page.
321
+ */
322
+ async pageOfElementId(elementId: string): Promise<number> {
323
+ this.#requireActive();
324
+ const offset = await this.#host.offsetOfElementId(elementId);
325
+ if (offset < 0) return -1;
326
+ return this.#host.pageOfOffset(offset);
327
+ }
328
+
329
+ /**
330
+ * Draws a decoration over the range `position` resolves to in the current section,
331
+ * and holds the intent so it survives a re-layout. Draw-only: the paginator keeps
332
+ * only the in-memory intent (id → position + class) and never persists it. The
333
+ * `position` is resolved against the frame-measured section text; its resolved
334
+ * grapheme span is converted to a UTF-16 offset range the frame paints over. A
335
+ * soft-miss — the anchor no longer resolves, or belongs to a section other than the
336
+ * one laid out — draws nothing and throws nothing; the intent is still retained so a
337
+ * later re-layout that brings the anchor back into view redraws it.
338
+ */
339
+ async decorate(decorationId: string, position: Position, className: string): Promise<void> {
340
+ this.#requireActive();
341
+ this.#decorations.set(decorationId, { position, className });
342
+ await this.#drawDecoration(decorationId, position, className);
343
+ }
344
+
345
+ /** Removes a decoration and drops its intent. A no-op for an unknown id. */
346
+ async undecorate(decorationId: string): Promise<void> {
347
+ const existed = this.#decorations.delete(decorationId);
348
+ if (this.#section === null) return;
349
+ if (existed) await this.#host.undecorate(decorationId);
350
+ }
351
+
352
+ /**
353
+ * Resolves `position` to a UTF-16 offset range over the section text and sends the
354
+ * frame the draw. A resolution miss (or a `Position` from another section) is a
355
+ * soft-miss: the frame is told to draw nothing (an empty range), never an error.
356
+ */
357
+ async #drawDecoration(decorationId: string, position: Position, className: string): Promise<void> {
358
+ const text = await this.#sectionText();
359
+ if (position.sectionId !== this.#section!.id) {
360
+ await this.#host.undecorate(decorationId);
361
+ return;
362
+ }
363
+ const resolved = resolvePosition(position, text);
364
+ if (resolved === undefined) {
365
+ await this.#host.undecorate(decorationId);
366
+ return;
367
+ }
368
+ const start = graphemeIndexToCodeUnitOffset(text, resolved.offset);
369
+ const end = graphemeIndexToCodeUnitOffset(text, resolved.offset + resolved.length);
370
+ await this.#host.decorate(decorationId, start, end, className);
371
+ }
372
+
373
+ /**
374
+ * Re-resolves and redraws every live decoration against the current section text —
375
+ * the M1-2 re-anchor pipeline, reused for decorations. Called after a re-layout so
376
+ * a highlight tracks the same text through a font-size or mode change. A decoration
377
+ * whose section is not the active one is simply not drawn (its intent is kept).
378
+ */
379
+ async #redrawDecorations(): Promise<void> {
380
+ if (this.#decorations.size === 0) return;
381
+ for (const [decorationId, { position, className }] of this.#decorations) {
382
+ await this.#drawDecoration(decorationId, position, className);
383
+ }
384
+ }
385
+
386
+ /** Within-chapter progress: current page, total (estimate/firm), and fraction. */
387
+ chapterProgress(): ChapterProgress {
388
+ this.#requireActive();
389
+ const state = this.#state!;
390
+ const totalPages = Math.max(1, state.pageCount);
391
+ return {
392
+ mode: this.#options!.mode,
393
+ page: this.#page,
394
+ totalPages: state.pageCount,
395
+ firm: state.firm,
396
+ fraction: totalPages <= 1 ? 0 : this.#page / (totalPages - 1),
397
+ realizedChunks: state.realizedChunks,
398
+ totalChunks: state.totalChunks,
399
+ };
400
+ }
401
+
402
+ /**
403
+ * Within-book progress. The facade supplies the current section's index and the
404
+ * book's section count; only this section is laid out, so the book fraction is
405
+ * an even-weight approximation across sections and {@link BookProgress.approximate}
406
+ * is always true.
407
+ */
408
+ bookProgress(sectionIndex: number, sectionCount: number): BookProgress {
409
+ const chapter = this.chapterProgress();
410
+ const sections = Math.max(1, sectionCount);
411
+ const bookFraction = clamp((sectionIndex + chapter.fraction) / sections, 0, 1);
412
+ return { ...chapter, sectionIndex, sectionCount, bookFraction, approximate: true };
413
+ }
414
+
415
+ /**
416
+ * Applies a new appearance theme stylesheet (see `themeStyleSheet`) to the
417
+ * current section, preserving the reading position across the swap. A theme is
418
+ * colours + background only, so chunk geometry is invariant under it: the same
419
+ * markup re-lays out to the same page count and the captured `Position` resolves
420
+ * back to the exact same page — the reader's place is held, not reset to 0 and
421
+ * not drifted, and the text does not reflow. The theme lives in the srcdoc (not
422
+ * over the wire), so it re-assembles the document; because geometry is invariant
423
+ * the anchor page is restored precisely. Before the first `paginate` it only
424
+ * records the theme for the next render.
425
+ *
426
+ * A no-op when the stylesheet is unchanged, so re-issuing the same appearance is
427
+ * free.
428
+ */
429
+ async setThemeCss(themeCss: string | undefined): Promise<void> {
430
+ if (this.#host.themeCss === themeCss) return;
431
+ await this.#reapply(themeCss, this.#options?.columnCount, this.#options?.columnGap);
432
+ }
433
+
434
+ /**
435
+ * Applies a live appearance change that may re-flow the layout — a new
436
+ * appearance stylesheet (font/size/line-height/alignment/hyphenation) and/or a
437
+ * new `columnCount` — preserving the reading position across it. Unlike
438
+ * {@link setThemeCss} (colours only, geometry invariant), a reflowing knob
439
+ * changes chunk geometry, so this reuses the same capture → re-layout → resolve →
440
+ * restore machinery as {@link switchMode}: it captures a `Position` for the
441
+ * current page, re-paginates with the new stylesheet and column count, and seeks
442
+ * back to the page that `Position` now resolves to. A same-text resolution miss
443
+ * degrades to page 0. Before the first `paginate` it only records the stylesheet
444
+ * for the next render. A no-op (same stylesheet, same column count) short-
445
+ * circuits, so re-issuing the same appearance is free.
446
+ */
447
+ async applyAppearance(
448
+ themeCss: string | undefined,
449
+ geometry: { columnCount?: number; columnGap?: number } = {},
450
+ ): Promise<void> {
451
+ const nextColumns = geometry.columnCount ?? this.#options?.columnCount;
452
+ const nextGap = geometry.columnGap ?? this.#options?.columnGap;
453
+ const sameCss = this.#host.themeCss === themeCss;
454
+ const sameColumns = this.#options === null || this.#options.columnCount === nextColumns;
455
+ const sameGap = this.#options === null || this.#options.columnGap === nextGap;
456
+ if (sameCss && sameColumns && sameGap) return;
457
+ await this.#reapply(themeCss, nextColumns, nextGap);
458
+ }
459
+
460
+ /**
461
+ * Sets the stylesheet and column geometry, re-paginates the current section, and
462
+ * restores the reading position by content anchor. Shared by {@link setThemeCss}
463
+ * (geometry invariant, exact page restored) and {@link applyAppearance} (geometry
464
+ * may change, nearest anchor page restored). Records the stylesheet and returns
465
+ * before the first `paginate` when no section is laid out yet.
466
+ */
467
+ async #reapply(
468
+ themeCss: string | undefined,
469
+ columnCount: number | undefined,
470
+ columnGap: number | undefined,
471
+ ): Promise<void> {
472
+ this.#host.setThemeCss(themeCss);
473
+ if (this.#section === null || this.#options === null) return;
474
+ const nextColumns = columnCount ?? this.#options.columnCount;
475
+ const nextGap = columnGap ?? this.#options.columnGap;
476
+ const options: PaginateOptions =
477
+ nextColumns === this.#options.columnCount && nextGap === this.#options.columnGap
478
+ ? this.#options
479
+ : { ...this.#options, columnCount: nextColumns, columnGap: nextGap };
480
+ const anchor = await this.positionOfPage(this.#page);
481
+ const state = await this.#host.paginate(this.#section, options);
482
+ this.#options = options;
483
+ this.#state = state;
484
+ // Same section, same text: the section-text cache survives the reflow, so
485
+ // the restore leg below resolves without another sectionText round trip.
486
+ const page = await this.pageOfPosition(anchor);
487
+ await this.goToPage(page >= 0 ? page : 0);
488
+ await this.#redrawDecorations();
489
+ }
490
+
491
+ /** Frame-side counts, for eviction/memory checks. Eviction itself lives in-frame. */
492
+ async diagnostics(): Promise<PaginatorDiagnostics> {
493
+ this.#requireActive();
494
+ return this.#host.diagnostics();
495
+ }
496
+
497
+ /** The section's concatenated chunk text, as the frame measures it (cached). */
498
+ async sectionText(): Promise<string> {
499
+ this.#requireActive();
500
+ return this.#sectionText();
501
+ }
502
+
503
+ /** Tears down the underlying host and frame. Idempotent. */
504
+ destroy(): void {
505
+ this.#host.destroy();
506
+ this.#section = null;
507
+ this.#options = null;
508
+ this.#state = null;
509
+ this.#text = null;
510
+ this.#decorations.clear();
511
+ }
512
+
513
+ async #sectionText(): Promise<string> {
514
+ if (this.#text === null) this.#text = await this.#host.sectionText();
515
+ return this.#text;
516
+ }
517
+
518
+ #resolveOptions(request: PaginateRequest): PaginateOptions {
519
+ return {
520
+ mode: request.mode ?? 'paginated',
521
+ pageWidth: request.pageWidth ?? this.#container.clientWidth,
522
+ pageHeight: request.pageHeight ?? this.#container.clientHeight,
523
+ columnGap: request.columnGap ?? DEFAULT_COLUMN_GAP,
524
+ chunkChars: request.chunkChars ?? DEFAULT_CHUNK_CHARS,
525
+ windowChunks: request.windowChunks ?? DEFAULT_WINDOW_CHUNKS,
526
+ columnCount: request.columnCount ?? DEFAULT_COLUMN_COUNT,
527
+ };
528
+ }
529
+
530
+ #requestFromOptions(): PaginateRequest {
531
+ const options = this.#options!;
532
+ return {
533
+ pageWidth: options.pageWidth,
534
+ pageHeight: options.pageHeight,
535
+ columnGap: options.columnGap,
536
+ chunkChars: options.chunkChars,
537
+ windowChunks: options.windowChunks,
538
+ columnCount: options.columnCount,
539
+ };
540
+ }
541
+
542
+ #requireActive(): void {
543
+ if (this.#section === null || this.#options === null || this.#state === null) {
544
+ throw new PaginatorError('no section is paginated; call paginate() first');
545
+ }
546
+ }
547
+ }
548
+
549
+ function clamp(value: number, low: number, high: number): number {
550
+ return Math.min(high, Math.max(low, value));
551
+ }
552
+
553
+ /**
554
+ * Maps a grapheme index (as `resolvePosition` returns) to a UTF-16 code-unit
555
+ * offset (as `pageOfOffset` and `offsetOfPage` speak) in `text`. `Intl.Segmenter`
556
+ * carries each grapheme's code-unit index, so the mapping is exact for surrogate
557
+ * pairs, combining sequences, and emoji clusters. An index at or past the end
558
+ * maps to the text length.
559
+ *
560
+ * The section text is segmented once and memoized (the paginator caches one
561
+ * section text at a time, so a single-entry memo suffices); each lookup is then a
562
+ * binary search over the shared grapheme-boundary helper, never a re-segmentation.
563
+ */
564
+ let memoizedText: string | null = null;
565
+ let memoizedGraphemes: readonly Grapheme[] = [];
566
+
567
+ function graphemeIndexToCodeUnitOffset(text: string, graphemeIndex: number): number {
568
+ if (text !== memoizedText) {
569
+ memoizedText = text;
570
+ memoizedGraphemes = segmentGraphemes(text);
571
+ }
572
+ return graphemeIndexToOffset(memoizedGraphemes, text.length, graphemeIndex);
573
+ }