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