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,980 @@
1
+ import { escapeXmlAttribute } from '../core/text.js';
2
+ export const CONTENT_ROOT_ID = 'wolfy-reader-content';
3
+ /** Class marking each per-chunk container the host emits into the frame body. */
4
+ export const CHUNK_CLASS = 'wolfy-reader-chunk';
5
+ /** Cumulative character offset of a chunk's first glyph into the section text. */
6
+ export const CHUNK_START_ATTR = 'data-chunk-start';
7
+ /** Cumulative character offset just past a chunk's last glyph. */
8
+ export const CHUNK_END_ATTR = 'data-chunk-end';
9
+ /** Zero-based index of a chunk in section order. */
10
+ export const CHUNK_INDEX_ATTR = 'data-chunk-index';
11
+ /** Class every draw-only decoration overlay box carries, alongside the caller's class. */
12
+ export const DECORATION_CLASS = 'wolfy-reader-decoration';
13
+ /**
14
+ * A minimal reset and nothing else. Column geometry belongs to the paginator
15
+ * and typography to the appearance controls; the host's job is to stay out of
16
+ * the way of both, so it must not put the body in a column context.
17
+ */
18
+ const RESET_CSS = [
19
+ 'html{box-sizing:border-box}',
20
+ '*,*::before,*::after{box-sizing:inherit}',
21
+ 'body{margin:0}',
22
+ 'img,svg{max-width:100%;height:auto}',
23
+ // A draw-only decoration overlay: pointer-transparent and given a visible default
24
+ // so a bare `decorate` shows without host CSS. It carries the caller's class too, so
25
+ // an appearance stylesheet can restyle it; a host that wants a different look targets
26
+ // its own class. The default is deliberately mild (translucent), below the text.
27
+ `.${DECORATION_CLASS}{pointer-events:none;background:rgba(255,214,0,0.4);border-radius:2px;mix-blend-mode:multiply}`,
28
+ ].join('');
29
+ /**
30
+ * Delivered as a meta element because a library with no server cannot set a
31
+ * header. That costs the three header-only directives — `frame-ancestors`,
32
+ * `sandbox` and `report-uri` — and means the element must be the first thing in
33
+ * the document, since nothing before it is covered.
34
+ *
35
+ * `script-src` names a per-render nonce rather than allowing inline script, so
36
+ * a script that somehow survived sanitization still cannot run: the nonce is
37
+ * unguessable and unreadable by anything that is not already executing. That
38
+ * also blocks `javascript:` URLs, which need `'unsafe-inline'` to run.
39
+ *
40
+ * `data:` is the only source of content because the frame's opaque origin is
41
+ * refused the host's `blob:` URLs. It grants no reach — a data URL carries its
42
+ * own bytes and fetches nothing — so egress stays closed.
43
+ */
44
+ function contentSecurityPolicy(nonce) {
45
+ return [
46
+ "default-src 'none'",
47
+ `script-src 'nonce-${nonce}'`,
48
+ "style-src 'unsafe-inline' data:",
49
+ 'img-src data:',
50
+ 'font-src data:',
51
+ "connect-src 'none'",
52
+ "form-action 'none'",
53
+ "base-uri 'none'",
54
+ ].join('; ');
55
+ }
56
+ export function createNonce() {
57
+ const bytes = new Uint8Array(16);
58
+ crypto.getRandomValues(bytes);
59
+ return [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('');
60
+ }
61
+ export function coordinationScript(hostOrigin, keyboardNav = true) {
62
+ return `(function(){
63
+ 'use strict';
64
+ var VERSION = 9;
65
+ var host = window.parent;
66
+ var target = ${JSON.stringify(hostOrigin)};
67
+ // Whether the host acts on forwarded nav keys. Baked in at document assembly
68
+ // (like the theme stylesheet), never sent over the wire, so it is not a
69
+ // protocol field and needs no version bump. It gates only preventDefault —
70
+ // forwarding stays unconditional and the host ignores what it has disabled.
71
+ var KEYBOARD_NAV = ${JSON.stringify(keyboardNav)};
72
+ var ROOT_ID = ${JSON.stringify(CONTENT_ROOT_ID)};
73
+ var CHUNK_CLASS = ${JSON.stringify(CHUNK_CLASS)};
74
+ var START_ATTR = ${JSON.stringify(CHUNK_START_ATTR)};
75
+ var END_ATTR = ${JSON.stringify(CHUNK_END_ATTR)};
76
+ function send(message){ try { host.postMessage(message, target); } catch (error) {} }
77
+
78
+ // Hand-maintained copy of asHostMessage from protocol.ts. Kept in step by hand
79
+ // because this script is a string template and cannot import. See protocol.ts.
80
+ function validateHost(data){
81
+ if (data === null || typeof data !== 'object' || data.v !== VERSION) return null;
82
+ if (typeof data.id !== 'number') return null;
83
+ var t = data.type;
84
+ if (t === 'ping' || t === 'measure' || t === 'relayout' || t === 'sectionText' || t === 'diagnostics') return data;
85
+ if (t === 'paginate') {
86
+ var o = data.options;
87
+ if (o === null || typeof o !== 'object') return null;
88
+ if (o.mode !== 'paginated' && o.mode !== 'scrolled') return null;
89
+ if (typeof o.pageWidth !== 'number' || typeof o.pageHeight !== 'number') return null;
90
+ if (typeof o.columnGap !== 'number' || typeof o.chunkChars !== 'number') return null;
91
+ if (typeof o.windowChunks !== 'number' || typeof o.columnCount !== 'number') return null;
92
+ return data;
93
+ }
94
+ if (t === 'goToPage' || t === 'offsetOfPage') return typeof data.page === 'number' ? data : null;
95
+ if (t === 'pageOfOffset') return typeof data.offset === 'number' ? data : null;
96
+ if (t === 'offsetOfElementId') return typeof data.elementId === 'string' ? data : null;
97
+ if (t === 'decorate') {
98
+ if (typeof data.decorationId !== 'string' || typeof data.className !== 'string') return null;
99
+ if (typeof data.start !== 'number' || typeof data.end !== 'number') return null;
100
+ return data;
101
+ }
102
+ if (t === 'undecorate') return typeof data.decorationId === 'string' ? data : null;
103
+ return null;
104
+ }
105
+
106
+ // Live pagination state, rebuilt on paginate and re-measured on relayout.
107
+ var layout = {
108
+ mode: 'paginated',
109
+ pageWidth: 0,
110
+ pageHeight: 0,
111
+ columnGap: 0,
112
+ columnCount: 1,
113
+ windowChunks: 2,
114
+ chunks: [], // { el, index, start, end, top, height, pages, firstPage }
115
+ pageCount: 0,
116
+ contentWidth: 0,
117
+ contentHeight: 0,
118
+ firm: false
119
+ };
120
+
121
+ function chunkContainers(){
122
+ var root = document.getElementById(ROOT_ID) || document.body;
123
+ var found = root.getElementsByClassName(CHUNK_CLASS);
124
+ var list = [];
125
+ for (var i = 0; i < found.length; i++) list.push(found[i]);
126
+ return list;
127
+ }
128
+
129
+ function numAttr(el, name){
130
+ var v = parseInt(el.getAttribute(name) || '', 10);
131
+ return isNaN(v) ? 0 : v;
132
+ }
133
+
134
+ // Measure one chunk's laid-out content extent. Each chunk is its own
135
+ // multi-column formatting context, so its horizontal content width (scrollWidth)
136
+ // divided by the page stride yields its page count. Range rects length-checked.
137
+ function measureChunkWidth(el){
138
+ // scrollWidth of a multi-column box grows with the number of columns, so it is
139
+ // the total painted width. Guard against a zero-width container.
140
+ var w = el.scrollWidth;
141
+ if (w > 0) return w;
142
+ var range = document.createRange();
143
+ try {
144
+ range.selectNodeContents(el);
145
+ var rects = range.getClientRects();
146
+ if (rects.length === 0) return 0;
147
+ var min = Infinity, max = -Infinity;
148
+ for (var i = 0; i < rects.length; i++){
149
+ if (rects[i].left < min) min = rects[i].left;
150
+ if (rects[i].right > max) max = rects[i].right;
151
+ }
152
+ return max > min ? (max - min) : 0;
153
+ } finally {
154
+ range.detach && range.detach();
155
+ }
156
+ }
157
+
158
+ // The width of a single CSS column. columnGap is the reader's one spacing unit:
159
+ // it is the page-edge margin AND the gutter between columns, so a page reads
160
+ // margin, col, margin, col, ..., margin. The text area is the page minus its two
161
+ // edge margins; N columns and (N-1) inter-column gaps then share that inner width.
162
+ function colWidth(){
163
+ var n = layout.columnCount > 0 ? layout.columnCount : 1;
164
+ var inner = layout.pageWidth - 2 * layout.columnGap;
165
+ if (n <= 1) return inner;
166
+ return (inner - (n - 1) * layout.columnGap) / n;
167
+ }
168
+ // The horizontal distance one page-turn translates the active chunk: N column
169
+ // strides. Each column stride is its width plus the gap that follows it, so N
170
+ // columns advance by N * (colWidth + columnGap). With the edge-margin geometry this
171
+ // equals pageWidth - columnGap, which lands every page's columns inside the same
172
+ // left/right margins — the mapping math (offsetOfPage/pageOfOffset) reads live box
173
+ // positions and this stride, so it needs no edge-margin term of its own.
174
+ function stride(){
175
+ var n = layout.columnCount > 0 ? layout.columnCount : 1;
176
+ return n * (colWidth() + layout.columnGap);
177
+ }
178
+
179
+ // The chunk (and its local page) that paints a given global page, or null. Shared
180
+ // by every page→chunk seam so the [firstPage, firstPage+pages) test lives once.
181
+ function chunkAtPage(page){
182
+ for (var i = 0; i < layout.chunks.length; i++){
183
+ var ch = layout.chunks[i];
184
+ if (page >= ch.firstPage && page < ch.firstPage + ch.pages) return ch;
185
+ }
186
+ return null;
187
+ }
188
+
189
+ // The chunk covering a section-text character offset, or null.
190
+ function chunkAtOffset(offset){
191
+ for (var i = 0; i < layout.chunks.length; i++){
192
+ var ch = layout.chunks[i];
193
+ if (offset >= ch.start && offset < ch.end) return ch;
194
+ }
195
+ return null;
196
+ }
197
+
198
+ // Realize a chunk so a Range can measure its real text even if it was evicted
199
+ // (content-visibility:auto skips layout for off-screen chunks). Every measuring
200
+ // seam calls this rather than poking the style ad hoc.
201
+ function realize(el){ el.style.contentVisibility = 'visible'; }
202
+
203
+ // (Re)apply geometry to every chunk container and recompute the page map. In
204
+ // paginated mode each chunk is an absolutely-positioned multi-column context and
205
+ // contributes ceil(contentWidth / pageStride) pages; boundaries are forced page
206
+ // breaks so per-chunk page counts simply sum. In scrolled mode chunks stack
207
+ // vertically in normal flow with no paging.
208
+ function relayout(){
209
+ var els = chunkContainers();
210
+ layout.chunks = [];
211
+ var root = document.getElementById(ROOT_ID) || document.body;
212
+ if (layout.mode === 'scrolled'){
213
+ // The document scrolls vertically to read, so restore that; but clip the
214
+ // horizontal axis so an over-wide element (a broad table, a long pre) cannot add
215
+ // a stray sideways scrollbar to what is a vertical reading surface.
216
+ document.documentElement.style.overflow = '';
217
+ document.body.style.overflow = '';
218
+ document.documentElement.style.overflowX = 'hidden';
219
+ document.body.style.overflowX = 'hidden';
220
+ root.style.position = '';
221
+ root.style.width = layout.pageWidth ? (layout.pageWidth + 'px') : '';
222
+ root.style.height = '';
223
+ var top = 0;
224
+ for (var i = 0; i < els.length; i++){
225
+ var el = els[i];
226
+ el.style.position = 'static';
227
+ el.style.columnWidth = 'auto';
228
+ el.style.width = layout.pageWidth ? (layout.pageWidth + 'px') : 'auto';
229
+ // Scrolled text gets the same left/right edge margins as a page (box-sizing is
230
+ // border-box, so the padding eats into pageWidth rather than overflowing it).
231
+ el.style.paddingLeft = layout.columnGap + 'px';
232
+ el.style.paddingRight = layout.columnGap + 'px';
233
+ el.style.height = 'auto';
234
+ el.style.contentVisibility = '';
235
+ var h = el.offsetHeight;
236
+ layout.chunks.push({
237
+ el: el, index: numAttr(el, ${JSON.stringify(CHUNK_INDEX_ATTR)}),
238
+ start: numAttr(el, START_ATTR), end: numAttr(el, END_ATTR),
239
+ top: top, height: h, pages: 0, firstPage: 0
240
+ });
241
+ top += h;
242
+ }
243
+ layout.pageCount = 0;
244
+ layout.contentWidth = layout.pageWidth;
245
+ layout.contentHeight = top;
246
+ layout.firm = true;
247
+ return;
248
+ }
249
+ // Paginated. The root box is the page viewport (overflow:hidden). The document
250
+ // must never scroll: each chunk is a multi-column context whose later columns lay
251
+ // out far to the right and a page turn translates them into view, so without this
252
+ // the off-page columns give html/body a wide scrollWidth and a stray scrollbar.
253
+ document.documentElement.style.overflow = 'hidden';
254
+ document.body.style.overflow = 'hidden';
255
+ root.style.position = 'relative';
256
+ root.style.width = layout.pageWidth + 'px';
257
+ root.style.height = layout.pageHeight + 'px';
258
+ root.style.overflow = 'hidden';
259
+ var pageStride = stride();
260
+ var pageAcc = 0;
261
+ var maxWidth = 0;
262
+ for (var j = 0; j < els.length; j++){
263
+ var c = els[j];
264
+ c.style.position = 'absolute';
265
+ c.style.top = '0';
266
+ // Inset the chunk by one margin so every page shows a left edge margin; the
267
+ // width is the page minus both edge margins, so a right margin falls out too.
268
+ // The edge margin here is positional (left/width), not padding — a chunk that
269
+ // was scrolled carries padding, which must be cleared so it is not inset twice.
270
+ c.style.left = layout.columnGap + 'px';
271
+ c.style.paddingLeft = '0';
272
+ c.style.paddingRight = '0';
273
+ c.style.height = layout.pageHeight + 'px';
274
+ // Each chunk is its own multi-column context. The CSS column width is one
275
+ // reader column (a fraction of the inner width for a 2-column layout); the page
276
+ // turn translates by stride() (N columns), so a page paints N columns at once.
277
+ c.style.columnWidth = colWidth() + 'px';
278
+ c.style.columnGap = layout.columnGap + 'px';
279
+ c.style.columnFill = 'auto';
280
+ c.style.width = (layout.pageWidth - 2 * layout.columnGap) + 'px';
281
+ // The chunk must NOT clip: its multi-column overflow (columns 2, 3, …) lays
282
+ // out to the right, and a page turn reveals a later column by translating the
283
+ // whole chunk left. A clip here would travel with the box and paint only the
284
+ // first column — every page after the first would be blank. The root box owns
285
+ // the viewport clip; the chunk stays visible so its columns can scroll in.
286
+ c.style.overflow = 'visible';
287
+ c.style.contentVisibility = 'visible';
288
+ var cw = measureChunkWidth(c);
289
+ var pages = cw <= 0 ? 1 : Math.max(1, Math.ceil((cw + layout.columnGap) / pageStride));
290
+ if (cw > maxWidth) maxWidth = cw;
291
+ layout.chunks.push({
292
+ el: c, index: numAttr(c, ${JSON.stringify(CHUNK_INDEX_ATTR)}),
293
+ start: numAttr(c, START_ATTR), end: numAttr(c, END_ATTR),
294
+ top: 0, height: layout.pageHeight, pages: pages, firstPage: pageAcc
295
+ });
296
+ pageAcc += pages;
297
+ }
298
+ layout.pageCount = pageAcc;
299
+ layout.contentWidth = maxWidth;
300
+ layout.contentHeight = layout.pageHeight;
301
+ layout.firm = true;
302
+ applyWindow(currentPage());
303
+ }
304
+
305
+ // content-visibility:auto on chunks outside the retained window. Geometry stays
306
+ // recorded in layout.chunks, so page numbers do not shift when a chunk is
307
+ // un-realized; it re-lays-out on return.
308
+ var activeChunk = 0;
309
+ function currentPage(){ return activeChunk; }
310
+ function applyWindow(centerPage){
311
+ var center = 0;
312
+ for (var i = 0; i < layout.chunks.length; i++){
313
+ var ch = layout.chunks[i];
314
+ if (centerPage >= ch.firstPage && centerPage < ch.firstPage + ch.pages){ center = i; break; }
315
+ }
316
+ activeChunk = center;
317
+ for (var k = 0; k < layout.chunks.length; k++){
318
+ var chunk = layout.chunks[k];
319
+ var near = Math.abs(k - center) <= layout.windowChunks;
320
+ chunk.el.style.contentVisibility = near ? 'visible' : 'auto';
321
+ chunk.el.style.containIntrinsicSize = near ? '' :
322
+ (layout.pageWidth + 'px ' + layout.pageHeight + 'px');
323
+ }
324
+ }
325
+
326
+ function realizedCount(){
327
+ var n = 0;
328
+ for (var i = 0; i < layout.chunks.length; i++){
329
+ if (layout.chunks[i].el.style.contentVisibility !== 'auto') n++;
330
+ }
331
+ return n;
332
+ }
333
+
334
+ function paginationState(){
335
+ return {
336
+ pageCount: layout.pageCount,
337
+ firm: layout.firm,
338
+ realizedChunks: realizedCount(),
339
+ totalChunks: layout.chunks.length,
340
+ contentWidth: layout.contentWidth,
341
+ contentHeight: layout.contentHeight
342
+ };
343
+ }
344
+
345
+ // Position the visible window so that page number \`page\` (0-based) is shown. Each
346
+ // chunk paints its own columns from its own left origin, so scrolling a page
347
+ // means translating the active chunk left by the intra-chunk column stride and
348
+ // hiding the others.
349
+ function goToPage(page){
350
+ if (layout.pageCount <= 0) return 0;
351
+ if (page < 0) page = 0;
352
+ if (page >= layout.pageCount) page = layout.pageCount - 1;
353
+ var target = chunkAtPage(page);
354
+ var localPage = target === null ? 0 : page - target.firstPage;
355
+ applyWindow(page);
356
+ for (var j = 0; j < layout.chunks.length; j++){
357
+ var c = layout.chunks[j];
358
+ if (c === target){
359
+ c.el.style.visibility = 'visible';
360
+ c.el.style.transform = 'translateX(' + (-(localPage * stride())) + 'px)';
361
+ } else {
362
+ c.el.style.visibility = 'hidden';
363
+ c.el.style.transform = '';
364
+ }
365
+ }
366
+ return page;
367
+ }
368
+
369
+ // Map a page (0-based) to the character offset of its first painted glyph.
370
+ // Column flow lays text out monotonically: the column — and therefore the page
371
+ // band — a character paints in never decreases as its text offset grows. So the
372
+ // first character of a page is found by binary search over the chunk's text
373
+ // (~log n getClientRects probes over <= ~8000 chars), never one probe per
374
+ // character. Characters with no client rects (zero-width anchors, collapsed
375
+ // whitespace — the empty-rect gotcha) are skipped by scanning forward to the
376
+ // next measurable character inside each probe.
377
+ function offsetOfPage(page){
378
+ if (page < 0 || page >= layout.pageCount) return -1;
379
+ var chunk = chunkAtPage(page);
380
+ if (chunk === null) return -1;
381
+ realize(chunk.el);
382
+ var localPage = page - chunk.firstPage;
383
+ var boxLeft = chunk.el.getBoundingClientRect().left;
384
+ var pageStride = stride();
385
+ // Collect the chunk's text nodes once, with cumulative start offsets, so a
386
+ // probe maps a chunk-local character index to (node, intra-node offset) by
387
+ // binary search rather than re-walking the tree.
388
+ var walker = document.createTreeWalker(chunk.el, NodeFilter.SHOW_TEXT, null);
389
+ var nodes = [], starts = [], total = 0, node;
390
+ while ((node = walker.nextNode())){
391
+ nodes.push(node); starts.push(total); total += node.data.length;
392
+ }
393
+ if (total === 0) return chunk.start;
394
+ var range = document.createRange();
395
+ // The local page band painting the character at chunk-local index i, or -1
396
+ // when the character has no client rects. The +1 tolerates sub-pixel
397
+ // rounding, mirroring the old band check's bandLeft - 1.
398
+ function pageAt(i){
399
+ var lo = 0, hi = nodes.length - 1;
400
+ while (lo < hi){
401
+ var mid = (lo + hi + 1) >> 1;
402
+ if (starts[mid] <= i) lo = mid; else hi = mid - 1;
403
+ }
404
+ range.setStart(nodes[lo], i - starts[lo]);
405
+ range.setEnd(nodes[lo], i - starts[lo] + 1);
406
+ var rects = range.getClientRects();
407
+ if (rects.length === 0) return -1;
408
+ return Math.floor((rects[0].left - boxLeft + 1) / pageStride);
409
+ }
410
+ // First measurable character at index >= i (exclusive bound limit), as
411
+ // { index, page }, or null when every character in [i, limit) is unmeasurable.
412
+ function probeFrom(i, limit){
413
+ for (var j = i; j < limit; j++){
414
+ var pg = pageAt(j);
415
+ if (pg >= 0) return { index: j, page: pg };
416
+ }
417
+ return null;
418
+ }
419
+ // Binary-search the smallest measurable index whose page is >= localPage;
420
+ // monotonicity of page-per-offset is what makes the halving sound.
421
+ var low = 0, high = total, best = -1, bestPage = -1;
422
+ while (low < high){
423
+ var midpoint = (low + high) >> 1;
424
+ var probe = probeFrom(midpoint, high);
425
+ if (probe === null){
426
+ high = midpoint;
427
+ } else if (probe.page >= localPage){
428
+ best = probe.index;
429
+ bestPage = probe.page;
430
+ high = probe.index;
431
+ } else {
432
+ low = probe.index + 1;
433
+ }
434
+ }
435
+ // A page whose band holds no measurable character keeps the old fallback: the
436
+ // chunk's own start offset.
437
+ if (best >= 0 && bestPage === localPage) return chunk.start + best;
438
+ return chunk.start;
439
+ }
440
+
441
+ // Inverse: which page paints the glyph at this section-text offset.
442
+ function pageOfOffset(offset){
443
+ var chunk = chunkAtOffset(offset);
444
+ if (chunk === null){
445
+ // Past the end: last page. Before the start: first page.
446
+ if (layout.chunks.length === 0) return 0;
447
+ return offset < layout.chunks[0].start ? 0 : layout.pageCount - 1;
448
+ }
449
+ realize(chunk.el);
450
+ var local = offset - chunk.start;
451
+ var walker = document.createTreeWalker(chunk.el, NodeFilter.SHOW_TEXT, null);
452
+ var acc = 0, node;
453
+ var boxLeft = chunk.el.getBoundingClientRect().left;
454
+ while ((node = walker.nextNode())){
455
+ var len = node.data.length;
456
+ if (local < acc + len){
457
+ var p = local - acc;
458
+ var range = document.createRange();
459
+ range.setStart(node, p);
460
+ range.setEnd(node, Math.min(p + 1, len));
461
+ var rects = range.getClientRects();
462
+ if (rects.length > 0){
463
+ var left = rects[0].left - boxLeft;
464
+ var localPage = Math.max(0, Math.floor(left / stride()));
465
+ return chunk.firstPage + Math.min(localPage, chunk.pages - 1);
466
+ }
467
+ return chunk.firstPage;
468
+ }
469
+ acc += len;
470
+ }
471
+ return chunk.firstPage;
472
+ }
473
+
474
+ // Map an element id to its character offset into the section's concatenated text.
475
+ // Find the element, walk up to its enclosing chunk container, then measure the
476
+ // text length from the chunk container's start to just before the element with a
477
+ // Range: range.toString().length added to the chunk's cumulative start offset is
478
+ // the offset. Returns -1 when no element carries the id.
479
+ function offsetOfElementId(elementId){
480
+ var el = document.getElementById(elementId);
481
+ if (el === null) return -1;
482
+ // Walk up to the enclosing chunk container.
483
+ var chunkEl = el;
484
+ while (chunkEl && !(chunkEl.nodeType === 1 && chunkEl.className &&
485
+ (' ' + chunkEl.className + ' ').indexOf(' ' + CHUNK_CLASS + ' ') !== -1)){
486
+ chunkEl = chunkEl.parentNode;
487
+ }
488
+ if (!chunkEl || chunkEl.nodeType !== 1) return -1;
489
+ // The element (or its chunk) may be un-realized; force it visible so the Range
490
+ // measures real text, mirroring offsetOfPage/pageOfOffset.
491
+ realize(chunkEl);
492
+ var chunkStart = numAttr(chunkEl, START_ATTR);
493
+ var range = document.createRange();
494
+ try {
495
+ range.setStart(chunkEl, 0);
496
+ range.setEndBefore(el);
497
+ // Touch the rects (length-checked) so the layout is up to date, matching the
498
+ // other seams; the offset itself comes from the range's text length.
499
+ var rects = range.getClientRects();
500
+ void (rects.length > 0);
501
+ return chunkStart + range.toString().length;
502
+ } finally {
503
+ range.detach && range.detach();
504
+ }
505
+ }
506
+
507
+ function sectionText(){
508
+ var text = '';
509
+ if (layout.chunks.length > 0){
510
+ for (var i = 0; i < layout.chunks.length; i++) text += layout.chunks[i].el.textContent || '';
511
+ } else {
512
+ // Before the first paginate the layout is empty; read the containers directly.
513
+ var els = chunkContainers();
514
+ for (var j = 0; j < els.length; j++) text += els[j].textContent || '';
515
+ }
516
+ return text;
517
+ }
518
+
519
+ function applyOptions(o){
520
+ layout.mode = o.mode;
521
+ layout.pageWidth = o.pageWidth;
522
+ layout.pageHeight = o.pageHeight;
523
+ layout.columnGap = o.columnGap;
524
+ layout.columnCount = o.columnCount > 0 ? o.columnCount : 1;
525
+ layout.windowChunks = o.windowChunks;
526
+ }
527
+
528
+ // Draw-only decorations. The host resolves a Position to a UTF-16 offset range over
529
+ // the tiled section text and sends it here; the frame maps that range to a DOM Range,
530
+ // reads its client rects, and paints one absolutely-positioned overlay box per rect.
531
+ // The library only DRAWS — it holds no annotation data beyond the live intent needed
532
+ // to re-paint across a re-layout, and never persists or fetches. Overlays are
533
+ // pointer-transparent (must not eat a selection or a link click) and layout-neutral
534
+ // (appended inside the chunk container, absolutely positioned, so they change no page
535
+ // geometry and translate with the chunk on a page turn).
536
+ var decorations = {}; // decorationId -> { start, end, className }
537
+ var decorationBoxes = {}; // decorationId -> [box elements]
538
+ var DECORATION_CLASS = ${JSON.stringify(DECORATION_CLASS)};
539
+
540
+ // Find the (node, offset) DOM point for a global UTF-16 offset into the concatenated
541
+ // chunk-container text — the inverse of offsetOfPoint used by selection. Walks the
542
+ // chunk containers, accumulating live textContent length, and stops at the node that
543
+ // spans the offset. textContent is DOM, not layout, so the accumulation walk must not
544
+ // realize the chunks it merely passes over — only the chunk that owns the offset is
545
+ // realized (the caller needs its client rects), so a decoration in a late chunk
546
+ // leaves the content-visibility eviction window intact. Returns null when the offset
547
+ // is past the realized text (a soft-miss the caller draws nothing for).
548
+ function pointAtOffset(target){
549
+ var chunks = chunkContainers();
550
+ var acc = 0;
551
+ for (var i = 0; i < chunks.length; i++){
552
+ var el = chunks[i];
553
+ var len = (el.textContent || '').length;
554
+ if (target <= acc + len){
555
+ realize(el);
556
+ var local = target - acc;
557
+ var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
558
+ var nodeAcc = 0, node;
559
+ while ((node = walker.nextNode())){
560
+ var nlen = node.data.length;
561
+ if (local <= nodeAcc + nlen){
562
+ return { chunk: el, node: node, offset: local - nodeAcc };
563
+ }
564
+ nodeAcc += nlen;
565
+ }
566
+ // Offset lands exactly at the chunk's end with no trailing text node.
567
+ return { chunk: el, node: el, offset: el.childNodes.length };
568
+ }
569
+ acc += len;
570
+ }
571
+ return null;
572
+ }
573
+
574
+ // Erase every overlay box painted for a decoration id.
575
+ function removeDecorationBoxes(decorationId){
576
+ var boxes = decorationBoxes[decorationId];
577
+ if (boxes){
578
+ for (var i = 0; i < boxes.length; i++){
579
+ if (boxes[i].parentNode) boxes[i].parentNode.removeChild(boxes[i]);
580
+ }
581
+ }
582
+ decorationBoxes[decorationId] = [];
583
+ }
584
+
585
+ // Paint one overlay box per client rect of the decoration's offset range. Boxes are
586
+ // appended inside the range's start chunk container (the positioned, page-turn-
587
+ // translated element) and positioned relative to it, so they ride the same transform
588
+ // as the text they cover. Guards an empty getClientRects() (the zero-width-anchor /
589
+ // empty-inline gotcha) — an empty rect list draws nothing, never a malformed box.
590
+ // Returns the number of boxes painted (0 on a soft-miss).
591
+ function paintDecoration(decorationId, start, end, className){
592
+ removeDecorationBoxes(decorationId);
593
+ if (end < start){ var swap = start; start = end; end = swap; }
594
+ var from = pointAtOffset(start);
595
+ var to = pointAtOffset(end);
596
+ if (from === null || to === null) return 0;
597
+ var range = document.createRange();
598
+ try {
599
+ range.setStart(from.node, from.offset);
600
+ range.setEnd(to.node, to.offset);
601
+ } catch (error) {
602
+ return 0;
603
+ }
604
+ var rects = range.getClientRects();
605
+ if (rects.length === 0) return 0;
606
+ // The overlay boxes live inside the chunk that owns the range start, positioned in
607
+ // that chunk's coordinate space so a page-turn transform carries them along. Each
608
+ // box's absolute offset is the rect's viewport position minus the chunk's padding-
609
+ // box origin; both are measured post-transform, so the offset is transform-invariant
610
+ // and the box rides the same translateX the text does on a page turn.
611
+ var container = from.chunk;
612
+ var origin = container.getBoundingClientRect();
613
+ var boxes = [];
614
+ for (var i = 0; i < rects.length; i++){
615
+ var rect = rects[i];
616
+ if (rect.width <= 0 || rect.height <= 0) continue;
617
+ var box = document.createElement('div');
618
+ box.className = DECORATION_CLASS + (className ? ' ' + className : '');
619
+ box.setAttribute('data-decoration', decorationId);
620
+ box.style.position = 'absolute';
621
+ box.style.pointerEvents = 'none';
622
+ box.style.left = (rect.left - origin.left) + 'px';
623
+ box.style.top = (rect.top - origin.top) + 'px';
624
+ box.style.width = rect.width + 'px';
625
+ box.style.height = rect.height + 'px';
626
+ container.appendChild(box);
627
+ boxes.push(box);
628
+ }
629
+ decorationBoxes[decorationId] = boxes;
630
+ return boxes.length;
631
+ }
632
+
633
+ // Re-paint every live decoration. Called after a relayout / window shift so overlays
634
+ // track the text their offset ranges cover across a re-flow.
635
+ function repaintDecorations(){
636
+ for (var decorationId in decorations){
637
+ if (!Object.prototype.hasOwnProperty.call(decorations, decorationId)) continue;
638
+ var d = decorations[decorationId];
639
+ paintDecoration(decorationId, d.start, d.end, d.className);
640
+ }
641
+ }
642
+
643
+ window.addEventListener('message', function(event){
644
+ if (event.source !== host) return;
645
+ var data = validateHost(event.data);
646
+ if (data === null) return;
647
+ var id = data.id;
648
+ if (data.type === 'ping') { send({ v: VERSION, type: 'pong', id: id }); return; }
649
+ if (data.type === 'measure') {
650
+ var root = document.getElementById(ROOT_ID) || document.documentElement;
651
+ send({ v: VERSION, type: 'measured', id: id, width: root.scrollWidth, height: root.scrollHeight });
652
+ return;
653
+ }
654
+ if (data.type === 'paginate') { applyOptions(data.options); relayout(); repaintDecorations(); send({ v: VERSION, type: 'paginated', id: id, state: paginationState() }); return; }
655
+ if (data.type === 'relayout') { relayout(); repaintDecorations(); send({ v: VERSION, type: 'paginated', id: id, state: paginationState() }); return; }
656
+ if (data.type === 'goToPage') { var moved = goToPage(data.page); repaintDecorations(); send({ v: VERSION, type: 'movedToPage', id: id, page: moved }); return; }
657
+ if (data.type === 'offsetOfPage') { send({ v: VERSION, type: 'offset', id: id, offset: offsetOfPage(data.page) }); return; }
658
+ if (data.type === 'pageOfOffset') { send({ v: VERSION, type: 'page', id: id, page: pageOfOffset(data.offset) }); return; }
659
+ if (data.type === 'offsetOfElementId') { send({ v: VERSION, type: 'offset', id: id, offset: offsetOfElementId(data.elementId) }); return; }
660
+ if (data.type === 'sectionText') { send({ v: VERSION, type: 'text', id: id, text: sectionText() }); return; }
661
+ if (data.type === 'diagnostics') {
662
+ send({ v: VERSION, type: 'diagnosticsReport', id: id,
663
+ domNodes: document.getElementsByTagName('*').length,
664
+ realizedChunks: realizedCount(), totalChunks: layout.chunks.length });
665
+ return;
666
+ }
667
+ if (data.type === 'decorate') {
668
+ decorations[data.decorationId] = { start: data.start, end: data.end, className: data.className };
669
+ var boxes = paintDecoration(data.decorationId, data.start, data.end, data.className);
670
+ send({ v: VERSION, type: 'decorated', id: id, boxes: boxes });
671
+ return;
672
+ }
673
+ if (data.type === 'undecorate') {
674
+ removeDecorationBoxes(data.decorationId);
675
+ delete decorationBoxes[data.decorationId];
676
+ delete decorations[data.decorationId];
677
+ send({ v: VERSION, type: 'decorated', id: id, boxes: 0 });
678
+ return;
679
+ }
680
+ });
681
+ document.addEventListener('securitypolicyviolation', function(event){
682
+ send({
683
+ v: VERSION,
684
+ type: 'violation',
685
+ directive: String(event.effectiveDirective || event.violatedDirective || ''),
686
+ blockedUri: String(event.blockedURI || '')
687
+ });
688
+ });
689
+ window.addEventListener('error', function(event){
690
+ send({ v: VERSION, type: 'error', message: String((event && event.message) || 'error') });
691
+ });
692
+ // Walk up from a node to an enclosing <a href>, or null if none. Shared by the
693
+ // click (link-click) path and the tap path so a link tap stays a link click.
694
+ function linkAncestor(node){
695
+ while (node && node.nodeType === 1) {
696
+ if (node.localName === 'a' && node.hasAttribute('href')) return node;
697
+ node = node.parentNode;
698
+ }
699
+ return null;
700
+ }
701
+ // Walk up from a node to an enclosing <img src> (or SVG <image>), or null. Mirrors
702
+ // linkAncestor so a tap on a figure is distinguishable from a page-turn tap. The
703
+ // src is already a data: URL — applyResources substituted the full-resolution bytes
704
+ // into it — so forwarding it re-uses those bytes; no blob URL is ever minted.
705
+ function imageAncestor(node){
706
+ while (node && node.nodeType === 1) {
707
+ var name = node.localName;
708
+ if (name === 'img' && node.getAttribute('src')) return node;
709
+ if (name === 'image' && (node.getAttribute('href') || node.getAttribute('xlink:href'))) return node;
710
+ node = node.parentNode;
711
+ }
712
+ return null;
713
+ }
714
+ document.addEventListener('click', function(event){
715
+ var link = linkAncestor(event.target);
716
+ if (link) {
717
+ event.preventDefault();
718
+ // Report the raw authored href, not node.href: the opaque origin resolves
719
+ // the property against a null base and mangles it. The host/facade parses.
720
+ send({ v: VERSION, type: 'linkclick', href: String(link.getAttribute('href') || '') });
721
+ }
722
+ });
723
+
724
+ // Navigation-relevant keydowns are forwarded to the host, which owns the key
725
+ // map (direction-aware). Only the keys the reader acts on are forwarded. The
726
+ // forwarding is unconditional (the wire is not gated), but preventDefault is:
727
+ // KEYBOARD_NAV is the host's keyboard-input setting baked in at document
728
+ // assembly, and when the host has keyboard input disabled it will ignore the
729
+ // message, so suppressing the key's default action too would leave it dead.
730
+ // Space is Space/Shift+Space paging (a near-universal reader convention); it is
731
+ // forwarded as the normalized tokens 'Space' / 'Shift+Space' because event.key
732
+ // is ' ' for both and the wire carries no modifier field. Every other key is
733
+ // left untouched.
734
+ var NAV_KEYS = { ArrowLeft:1, ArrowRight:1, ArrowUp:1, ArrowDown:1, PageUp:1, PageDown:1, Home:1, End:1 };
735
+ document.addEventListener('keydown', function(event){
736
+ var isSpace = event.key === ' ' || event.key === 'Spacebar';
737
+ // Keyboard equivalent of a figure tap: Enter/Space on a focused image opens the
738
+ // same host-side zoom overlay a pointer tap does, so zoom is reachable without a
739
+ // pointer. Checked before paging so Space-to-activate wins on a focused image.
740
+ // Images are made focusable below (markImages); imageAncestor also catches
741
+ // Enter fired on a wrapper the image sits inside.
742
+ if (event.key === 'Enter' || isSpace) {
743
+ var img = imageAncestor(event.target);
744
+ if (img) {
745
+ event.preventDefault();
746
+ var isrc = img.getAttribute('src') || img.getAttribute('href') || img.getAttribute('xlink:href') || '';
747
+ send({ v: VERSION, type: 'imagetap', src: String(isrc), alt: String(img.getAttribute('alt') || '') });
748
+ return;
749
+ }
750
+ }
751
+ if (NAV_KEYS[event.key] === 1 || isSpace) {
752
+ if (KEYBOARD_NAV) event.preventDefault();
753
+ send({ v: VERSION, type: 'key', key: isSpace ? (event.shiftKey ? 'Shift+Space' : 'Space') : event.key });
754
+ }
755
+ });
756
+
757
+ // A figure is only tap-to-zoom by default; making each content image focusable and
758
+ // button-roled is what lets a keyboard user reach the zoom overlay. Marked as chunks
759
+ // realize (content is virtualized), idempotently, without touching the sandbox: these
760
+ // are ARIA/tabindex attributes only, no new capability.
761
+ function markImage(img){
762
+ if (img.getAttribute('data-wr-activatable') === '1') return;
763
+ img.setAttribute('data-wr-activatable', '1');
764
+ if (!img.hasAttribute('tabindex')) img.setAttribute('tabindex', '0');
765
+ if (!img.hasAttribute('role')) img.setAttribute('role', 'button');
766
+ if (!img.getAttribute('aria-label')) {
767
+ var label = img.getAttribute('alt') || 'Image';
768
+ img.setAttribute('aria-label', label + ' — press Enter to zoom');
769
+ }
770
+ }
771
+ function markImages(scope){
772
+ if (!scope || !scope.getElementsByTagName) return;
773
+ var imgs = scope.getElementsByTagName('img');
774
+ for (var i = 0; i < imgs.length; i++) markImage(imgs[i]);
775
+ }
776
+ function setupImageActivation(){
777
+ var root = document.getElementById(ROOT_ID) || document.body;
778
+ if (!root) return;
779
+ // Content ships in the initial srcdoc, so a one-shot pass marks everything present
780
+ // at parse; the observer then catches images added by later chunk realization.
781
+ markImages(root);
782
+ if (typeof MutationObserver === 'function') {
783
+ new MutationObserver(function(records){
784
+ for (var i = 0; i < records.length; i++) {
785
+ var added = records[i].addedNodes;
786
+ for (var j = 0; j < added.length; j++) {
787
+ var n = added[j];
788
+ if (!n || n.nodeType !== 1) continue;
789
+ if (n.localName === 'img') markImage(n);
790
+ else markImages(n);
791
+ }
792
+ }
793
+ }).observe(root, { childList: true, subtree: true });
794
+ }
795
+ }
796
+ if (document.readyState === 'loading') {
797
+ document.addEventListener('DOMContentLoaded', setupImageActivation);
798
+ } else {
799
+ setupImageActivation();
800
+ }
801
+
802
+ // Pointer/touch: a drag that clears the threshold and is horizontal-dominant is
803
+ // a swipe; a small movement that is not on a link is a tap. The host maps both
804
+ // (direction-aware). A link tap is left to the click handler as a linkclick.
805
+ var SWIPE_THRESHOLD = 30;
806
+ var TAP_SLOP = 10;
807
+ var gesture = null; // { x, y, target }
808
+ function beginGesture(x, y, target){ gesture = { x: x, y: y, target: target }; }
809
+ function endGesture(x, y){
810
+ if (gesture === null) return;
811
+ var dx = x - gesture.x;
812
+ var dy = y - gesture.y;
813
+ var start = gesture;
814
+ gesture = null;
815
+ if (Math.abs(dx) > SWIPE_THRESHOLD && Math.abs(dx) > Math.abs(dy)) {
816
+ // A horizontal drag that left a live text selection is the reader selecting
817
+ // text, not turning a page. Suppress the swipe so selection wins; the trailing
818
+ // pointerup still flushes the selection to the host.
819
+ var sel = document.getSelection();
820
+ if (sel !== null && !sel.isCollapsed && sel.toString().length !== 0) return;
821
+ send({ v: VERSION, type: 'swipe', dx: dx, dy: dy });
822
+ return;
823
+ }
824
+ if (Math.abs(dx) <= TAP_SLOP && Math.abs(dy) <= TAP_SLOP && !linkAncestor(start.target)) {
825
+ // A tap on an image (and not a link) opens the host-side zoom overlay: forward
826
+ // the image's resolved data: URL + alt. Every other tap is a page-turn gesture
827
+ // the host maps to a zone — unchanged, so tapIntent never regresses.
828
+ var image = imageAncestor(start.target);
829
+ if (image) {
830
+ var src = image.getAttribute('src') || image.getAttribute('href') || image.getAttribute('xlink:href') || '';
831
+ send({ v: VERSION, type: 'imagetap', src: String(src), alt: String(image.getAttribute('alt') || '') });
832
+ return;
833
+ }
834
+ send({ v: VERSION, type: 'tap', x: x, y: y, width: window.innerWidth, height: window.innerHeight });
835
+ }
836
+ }
837
+ if (typeof window.PointerEvent === 'function') {
838
+ document.addEventListener('pointerdown', function(event){
839
+ if (event.isPrimary === false) return;
840
+ beginGesture(event.clientX, event.clientY, event.target);
841
+ });
842
+ document.addEventListener('pointerup', function(event){
843
+ endGesture(event.clientX, event.clientY);
844
+ });
845
+ document.addEventListener('pointercancel', function(){ gesture = null; });
846
+ } else {
847
+ document.addEventListener('touchstart', function(event){
848
+ var t = event.changedTouches[0];
849
+ if (t) beginGesture(t.clientX, t.clientY, event.target);
850
+ });
851
+ document.addEventListener('touchend', function(event){
852
+ var t = event.changedTouches[0];
853
+ if (t) endGesture(t.clientX, t.clientY);
854
+ });
855
+ document.addEventListener('touchcancel', function(){ gesture = null; });
856
+ }
857
+ // Text selection is forwarded as a semantic offset range, not caught on the host:
858
+ // the selection lives inside this opaque-origin frame's document and never reaches
859
+ // the parent. The host cannot read the frame's Selection, so the frame computes
860
+ // the UTF-16 offset range over the same tiled section text sectionText() reports
861
+ // and forwards { start, end, text }. Same forwarding reasoning as link/key/tap.
862
+ function enclosingChunk(node){
863
+ var el = node && node.nodeType === 1 ? node : (node ? node.parentNode : null);
864
+ while (el && el.nodeType === 1){
865
+ if (el.className && (' ' + el.className + ' ').indexOf(' ' + CHUNK_CLASS + ' ') !== -1) return el;
866
+ el = el.parentNode;
867
+ }
868
+ return null;
869
+ }
870
+ // UTF-16 offset of (node, offset) into the concatenated section text: sum every
871
+ // prior chunk container's text length, then add the text length from this chunk's
872
+ // start up to the boundary via a Range. Returns -1 if the point is not inside a
873
+ // realized chunk container (a selection anchored outside the tiled text).
874
+ function offsetOfPoint(node, offset){
875
+ var chunk = enclosingChunk(node);
876
+ if (chunk === null) return -1;
877
+ var chunks = chunkContainers();
878
+ var base = 0;
879
+ for (var i = 0; i < chunks.length; i++){
880
+ if (chunks[i] === chunk) break;
881
+ base += (chunks[i].textContent || '').length;
882
+ }
883
+ var range = document.createRange();
884
+ try {
885
+ range.setStart(chunk, 0);
886
+ range.setEnd(node, offset);
887
+ return base + range.toString().length;
888
+ } catch (error) {
889
+ return -1;
890
+ } finally {
891
+ range.detach && range.detach();
892
+ }
893
+ }
894
+ function forwardSelection(){
895
+ var selection = document.getSelection();
896
+ if (selection === null || selection.rangeCount === 0 || selection.isCollapsed) return;
897
+ var text = selection.toString();
898
+ if (text.length === 0) return;
899
+ var range = selection.getRangeAt(0);
900
+ // Guard the zero-width-anchor / empty-inline gotcha: a selection whose rects are
901
+ // empty carries no painted geometry, so forwarding it would be a malformed range.
902
+ if (range.getClientRects().length === 0) return;
903
+ var start = offsetOfPoint(range.startContainer, range.startOffset);
904
+ var end = offsetOfPoint(range.endContainer, range.endOffset);
905
+ if (start < 0 || end < 0) return;
906
+ if (start > end){ var swap = start; start = end; end = swap; }
907
+ send({ v: VERSION, type: 'selection', start: start, end: end, text: text });
908
+ }
909
+ // A completed selection is a selectionchange settled by a pointer/key release, so
910
+ // forward on release rather than on every intermediate selectionchange (which
911
+ // fires per character during a drag). A bare click collapses the selection and is
912
+ // dropped by the isCollapsed guard above.
913
+ var selectionDirty = false;
914
+ document.addEventListener('selectionchange', function(){ selectionDirty = true; });
915
+ function flushSelection(){ if (selectionDirty){ selectionDirty = false; forwardSelection(); } }
916
+ document.addEventListener('pointerup', flushSelection);
917
+ document.addEventListener('keyup', flushSelection);
918
+ document.addEventListener('mouseup', flushSelection);
919
+
920
+ if (document.readyState === 'loading') {
921
+ document.addEventListener('DOMContentLoaded', function(){ send({ v: VERSION, type: 'ready' }); });
922
+ } else {
923
+ send({ v: VERSION, type: 'ready' });
924
+ }
925
+ })();`;
926
+ }
927
+ /**
928
+ * Wraps each chunk in its own container element, tagging it with the cumulative
929
+ * character range it covers in the section's concatenated text. Offsets are
930
+ * end-exclusive: chunk *i* covers `[start, end)`, and chunk *i+1* starts exactly
931
+ * at the previous `end`, so the ranges tile the section text without gap or
932
+ * overlap. The frame reads these attributes to build per-chunk multi-column
933
+ * contexts and to map pages to character offsets.
934
+ *
935
+ * The container is a `<div class="wolfy-reader-chunk">`; the char count comes from
936
+ * `chunkElement` (`textContent.length` of the chunk's nodes), so it matches what
937
+ * the frame measures with `textContent` at runtime.
938
+ */
939
+ export function assembleChunkedBody(chunks) {
940
+ const parts = [];
941
+ let offset = 0;
942
+ for (let index = 0; index < chunks.length; index += 1) {
943
+ const chunk = chunks[index];
944
+ const start = offset;
945
+ const end = offset + chunk.chars;
946
+ offset = end;
947
+ parts.push(`<div class="${CHUNK_CLASS}" ${CHUNK_INDEX_ATTR}="${index}" ` +
948
+ `${CHUNK_START_ATTR}="${start}" ${CHUNK_END_ATTR}="${end}">${chunk.html}</div>`);
949
+ }
950
+ return parts.join('');
951
+ }
952
+ export function assembleFrameDocument(parts) {
953
+ // The coordination script sits in the head, before any content, so violations
954
+ // raised while the body is still parsing are already being listened for. It
955
+ // announces readiness on DOMContentLoaded, not on evaluation.
956
+ //
957
+ // The theme stylesheet follows the minimal reset and precedes the publisher's
958
+ // headHtml: its `@layer` is declared first so book styles outrank it on their
959
+ // own properties, but the `--wr-*` variables it defines are names the book does
960
+ // not know and cannot clobber.
961
+ return [
962
+ '<!doctype html>',
963
+ '<html>',
964
+ '<head>',
965
+ `<meta http-equiv="Content-Security-Policy" content="${escapeXmlAttribute(contentSecurityPolicy(parts.nonce))}">`,
966
+ '<meta charset="utf-8">',
967
+ `<script nonce="${parts.nonce}">${coordinationScript(parts.hostOrigin, parts.keyboardNav !== false)}</script>`,
968
+ `<style>${RESET_CSS}</style>`,
969
+ ...(parts.themeCss !== undefined && parts.themeCss !== '' ? [`<style>${parts.themeCss}</style>`] : []),
970
+ parts.headHtml,
971
+ '</head>',
972
+ '<body>',
973
+ `<div id="${CONTENT_ROOT_ID}">`,
974
+ parts.bodyHtml,
975
+ '</div>',
976
+ '</body>',
977
+ '</html>',
978
+ ].join('\n');
979
+ }
980
+ //# sourceMappingURL=frame.js.map