vomebook commited on
Commit
7e93960
·
1 Parent(s): dc4279b

fix: repair Reader media playback and toolbar compatibility

Browse files

Extract shared full-text search control and cancel pending input on disposal.

app.py CHANGED
@@ -2099,7 +2099,8 @@ async def api_reader_bucket_resource(path: str = Query(...), request: Request =
2099
  url_validator=lambda url: validate_reader_bucket_redirect_url(url, source_url),
2100
  validation_scope=(validate_reader_bucket_redirect_url, source_url),
2101
  cache_control=reader_bucket_cache_control(resource_path),
2102
- max_bytes=READER_DOCUMENT_MAX_BYTES if suffix == "pdf" else READER_RESOURCE_MAX_BYTES,
 
2103
  media_type=media_type,
2104
  )
2105
  try:
@@ -2383,6 +2384,9 @@ async def api_reader_content(request: Request, url: Optional[str] = Query(defaul
2383
  target_url = validate_reader_source_url(url)
2384
  except ValueError as exc:
2385
  return JSONResponse({"error": str(exc)}, status_code=403)
 
 
 
2386
  return await stream_reader_document(request, target_url)
2387
 
2388
 
@@ -2439,7 +2443,10 @@ async def _stream_reader_document(
2439
  url_validator=validate_reader_redirect_url, cache_control: str | None = None,
2440
  max_bytes: int | None = None, media_type: str | None = None, validation_scope=None, _share_range: bool = True,
2441
  ):
2442
- if max_bytes is None:
 
 
 
2443
  max_bytes = READER_DOCUMENT_MAX_BYTES
2444
  forwarded_headers = {
2445
  name: request.headers[name]
@@ -2492,7 +2499,7 @@ async def _stream_reader_document(
2492
  upstream = lease.response = await open_download_response(
2493
  app.state.http_session,
2494
  target_url,
2495
- aiohttp.ClientTimeout(total=180, connect=15, sock_read=60),
2496
  method=request.method,
2497
  request_headers=forwarded_headers,
2498
  url_validator=url_validator,
@@ -2509,7 +2516,12 @@ async def _stream_reader_document(
2509
  if is_immutable_reader_asset(target_url)
2510
  else headers.get("Cache-Control", "public, max-age=300")
2511
  )
2512
- headers["Content-Encoding"] = "identity"
 
 
 
 
 
2513
  if media_type:
2514
  headers["Content-Type"] = media_type
2515
  status = upstream.status
@@ -2523,7 +2535,7 @@ async def _stream_reader_document(
2523
  length = int(declared_length)
2524
  except (TypeError, ValueError):
2525
  return JSONResponse({"error": "上游阅读文件长度无效"}, status_code=502)
2526
- if length < 0 or length > max_bytes:
2527
  return JSONResponse({"error": "阅读文件过大"}, status_code=413)
2528
  content_range = upstream.headers.get("Content-Range")
2529
  if status == 206:
@@ -2534,11 +2546,11 @@ async def _stream_reader_document(
2534
  if (range_start > range_end or range_end >= total_length
2535
  or (length is not None and length != range_end - range_start + 1)):
2536
  return JSONResponse({"error": "上游阅读文件范围无效"}, status_code=502)
2537
- if total_length > max_bytes:
2538
  return JSONResponse({"error": "阅读文件过大"}, status_code=413)
2539
  elif status == 416:
2540
  match = re.fullmatch(r"bytes\s+\*/([0-9]{1,20})", str(content_range or ""))
2541
- if match and int(match.group(1)) > max_bytes:
2542
  return JSONResponse({"error": "阅读文件过大"}, status_code=413)
2543
  if request.method == "HEAD" or status == 304:
2544
  return Response(status_code=status, headers=headers)
@@ -2555,7 +2567,7 @@ async def _stream_reader_document(
2555
  try:
2556
  async for chunk in upstream.content.iter_chunked(65536):
2557
  total += len(chunk)
2558
- if total > max_bytes:
2559
  raise ReaderDocumentLimitError("阅读文件过大")
2560
  if cacheable:
2561
  chunks.append(chunk)
@@ -2907,4 +2919,3 @@ async def shared_reader_range_response(request, target_url, range_key, options):
2907
  finally:
2908
  if not transferred:
2909
  await subscription.release()
2910
-
 
2099
  url_validator=lambda url: validate_reader_bucket_redirect_url(url, source_url),
2100
  validation_scope=(validate_reader_bucket_redirect_url, source_url),
2101
  cache_control=reader_bucket_cache_control(resource_path),
2102
+ max_bytes=(None if media_type.startswith(("audio/", "video/")) else
2103
+ READER_DOCUMENT_MAX_BYTES if suffix == "pdf" else READER_RESOURCE_MAX_BYTES),
2104
  media_type=media_type,
2105
  )
2106
  try:
 
2384
  target_url = validate_reader_source_url(url)
2385
  except ValueError as exc:
2386
  return JSONResponse({"error": str(exc)}, status_code=403)
2387
+ source_type = READER_RESOURCE_TYPES.get(unquote(urlparse(target_url).path).rsplit(".", 1)[-1].lower(), "")
2388
+ if source_type.startswith(("audio/", "video/")):
2389
+ return await stream_reader_document(request, target_url, media_type=source_type)
2390
  return await stream_reader_document(request, target_url)
2391
 
2392
 
 
2443
  url_validator=validate_reader_redirect_url, cache_control: str | None = None,
2444
  max_bytes: int | None = None, media_type: str | None = None, validation_scope=None, _share_range: bool = True,
2445
  ):
2446
+ # Native media is streamed, not parsed or buffered as a complete document.
2447
+ # Bound transport memory and idle time instead of rejecting large recordings.
2448
+ is_media_stream = bool(media_type and media_type.startswith(("audio/", "video/")))
2449
+ if max_bytes is None and not is_media_stream:
2450
  max_bytes = READER_DOCUMENT_MAX_BYTES
2451
  forwarded_headers = {
2452
  name: request.headers[name]
 
2499
  upstream = lease.response = await open_download_response(
2500
  app.state.http_session,
2501
  target_url,
2502
+ aiohttp.ClientTimeout(total=None if is_media_stream else 180, connect=15, sock_read=60),
2503
  method=request.method,
2504
  request_headers=forwarded_headers,
2505
  url_validator=url_validator,
 
2516
  if is_immutable_reader_asset(target_url)
2517
  else headers.get("Cache-Control", "public, max-age=300")
2518
  )
2519
+ # Gecko treats any Content-Encoding header as compressed media and
2520
+ # disables length/range seeking. GZipMiddleware already excludes media.
2521
+ if is_media_stream:
2522
+ headers.pop("Content-Encoding", None)
2523
+ else:
2524
+ headers["Content-Encoding"] = "identity"
2525
  if media_type:
2526
  headers["Content-Type"] = media_type
2527
  status = upstream.status
 
2535
  length = int(declared_length)
2536
  except (TypeError, ValueError):
2537
  return JSONResponse({"error": "上游阅读文件长度无效"}, status_code=502)
2538
+ if length < 0 or (max_bytes is not None and length > max_bytes):
2539
  return JSONResponse({"error": "阅读文件过大"}, status_code=413)
2540
  content_range = upstream.headers.get("Content-Range")
2541
  if status == 206:
 
2546
  if (range_start > range_end or range_end >= total_length
2547
  or (length is not None and length != range_end - range_start + 1)):
2548
  return JSONResponse({"error": "上游阅读文件范围无效"}, status_code=502)
2549
+ if max_bytes is not None and total_length > max_bytes:
2550
  return JSONResponse({"error": "阅读文件过大"}, status_code=413)
2551
  elif status == 416:
2552
  match = re.fullmatch(r"bytes\s+\*/([0-9]{1,20})", str(content_range or ""))
2553
+ if match and max_bytes is not None and int(match.group(1)) > max_bytes:
2554
  return JSONResponse({"error": "阅读文件过大"}, status_code=413)
2555
  if request.method == "HEAD" or status == 304:
2556
  return Response(status_code=status, headers=headers)
 
2567
  try:
2568
  async for chunk in upstream.content.iter_chunked(65536):
2569
  total += len(chunk)
2570
+ if max_bytes is not None and total > max_bytes:
2571
  raise ReaderDocumentLimitError("阅读文件过大")
2572
  if cacheable:
2573
  chunks.append(chunk)
 
2919
  finally:
2920
  if not transferred:
2921
  await subscription.release()
 
scripts/check_reader_release.mjs CHANGED
@@ -182,6 +182,7 @@ function crossCheck(githubRoot, hfRoot) {
182
  "reader-runtime.js",
183
  "reader-format-adapters.js",
184
  "reader-security.js",
 
185
  "pdf-worker-wrapper.mjs"
186
  ];
187
  for (const file of exact) {
 
182
  "reader-runtime.js",
183
  "reader-format-adapters.js",
184
  "reader-security.js",
185
+ "reader-search-controller.mjs",
186
  "pdf-worker-wrapper.mjs"
187
  ];
188
  for (const file of exact) {
static/icons/LICENSE-lucide ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ISC License
2
+
3
+ Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as part of Feather (MIT). All other copyright (c) for Lucide are held by Lucide Contributors 2022.
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
static/reader-resources.js CHANGED
@@ -100,6 +100,7 @@
100
  "reader-runtime.js",
101
  "reader-format-adapters.js",
102
  "reader-security.js",
 
103
  "reader-pdf-text.js",
104
  "reader-v3.mjs",
105
  "reader-book-text.js",
 
100
  "reader-runtime.js",
101
  "reader-format-adapters.js",
102
  "reader-security.js",
103
+ "reader-search-controller.mjs",
104
  "reader-pdf-text.js",
105
  "reader-v3.mjs",
106
  "reader-book-text.js",
static/reader-search-controller.mjs ADDED
@@ -0,0 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Shared search presentation and task ownership; format matching stays in Reader.
2
+ export function createReaderSearchController({
3
+ document, runtime, signal, nextGeneration, isCurrent, updateSearch,
4
+ backend, navigation, panel
5
+ }) {
6
+ const search = runtime.state.search;
7
+ const state = {
8
+ page: null, failedPage: null, complete: false,
9
+ pdfInProgress: false, chapterInProgress: false, pageLoader: null
10
+ };
11
+ let inputTimer = 0, disposed = false;
12
+ let renderedResults = [], renderedOffset = -1;
13
+ const listeners = [];
14
+ const active = () => !disposed && !signal.aborted;
15
+ const current = generation => active() && isCurrent(generation);
16
+ const busy = () => state.pdfInProgress || state.chapterInProgress;
17
+ const pagedMode = () => ["epub-chapters", "pdf", "pdf-pages", "foliate"].includes(backend.mode());
18
+
19
+ const view = document.createElement("section");
20
+ view.id = "full-search-view";
21
+ view.className = "reader-panel-view full-search-view";
22
+ view.dataset.panelView = "full-search";
23
+ view.hidden = true;
24
+ view.innerHTML =
25
+ '<div class="full-search-bar"><input id="full-search-input" class="full-search-input" type="search" placeholder="搜索正文" aria-label="搜索正文"><button id="full-search-clear" class="icon-button" type="button" aria-label="清除全文搜索" title="清除">×</button></div><div id="full-search-status" class="full-search-status" role="status">输入关键词搜索正文</div><div class="full-search-nav"><button id="full-search-prev" type="button" disabled>上一个</button><button id="full-search-next" type="button" disabled>下一个</button></div><div id="full-search-results" class="full-search-results"></div>';
26
+ document.querySelector("#history-panel").insertBefore(view, document.querySelector(".reader-panel-tabs"));
27
+ const button = document.querySelector("#full-search-toggle");
28
+ button.textContent = "全文搜索";
29
+ button.setAttribute("aria-label", "全文搜索");
30
+ const input = view.querySelector("#full-search-input");
31
+ const status = view.querySelector("#full-search-status");
32
+ const results = view.querySelector("#full-search-results");
33
+ const clear = view.querySelector("#full-search-clear");
34
+ clear.hidden = true;
35
+ const pagination = document.createElement("div");
36
+ pagination.className = "full-search-pagination";
37
+ pagination.hidden = true;
38
+ pagination.innerHTML =
39
+ '<button id="full-search-page-prev" type="button">上一页</button><label>第 <input id="full-search-page" type="number" min="1" value="1" aria-label="搜索结果页码"> 页 <span id="full-search-pages"></span></label><button id="full-search-page-next" type="button">下一页</button>';
40
+ results.before(pagination);
41
+ const cancel = document.createElement("button");
42
+ cancel.type = "button";
43
+ cancel.id = "full-search-cancel";
44
+ cancel.className = "text-action";
45
+ cancel.textContent = "取消搜索";
46
+ cancel.hidden = true;
47
+ status.after(cancel);
48
+ const retry = document.createElement("button");
49
+ retry.type = "button";
50
+ retry.id = "full-search-retry";
51
+ retry.className = "text-action";
52
+ retry.textContent = "重试搜索";
53
+ retry.hidden = true;
54
+ cancel.after(retry);
55
+
56
+ function listen(target, type, callback) {
57
+ target.addEventListener(type, callback);
58
+ listeners.push(() => target.removeEventListener(type, callback));
59
+ }
60
+ function clearInputTimer() {
61
+ clearTimeout(inputTimer);
62
+ inputTimer = 0;
63
+ }
64
+ function updateButton(open) {
65
+ button.textContent = open ? "关闭全文搜索" : "全文搜索";
66
+ button.setAttribute("aria-label", button.textContent);
67
+ }
68
+ function updateClear() {
69
+ clear.hidden = !input.value;
70
+ }
71
+ function updateNavigation() {
72
+ view.querySelectorAll(".full-search-nav button").forEach(node => {
73
+ node.disabled = !search.results.length;
74
+ });
75
+ }
76
+ function updatePagination() {
77
+ const page = state.page;
78
+ pagination.hidden = !page || !page.total;
79
+ if (!page) return;
80
+ const pages = Math.ceil(page.total / page.pageSize);
81
+ const pageInput = pagination.querySelector("#full-search-page");
82
+ pageInput.value = String(Math.floor(page.offset / page.pageSize) + 1);
83
+ pageInput.max = String(Math.max(1, pages));
84
+ pageInput.style.setProperty("--page-width", `${pageInput.max.length}ch`);
85
+ pagination.querySelector("#full-search-pages").textContent = `/ ${pages}`;
86
+ pagination.querySelector("#full-search-page-prev").disabled = busy() || page.offset === 0;
87
+ pagination.querySelector("#full-search-page-next").disabled = busy() || page.offset + page.pageSize >= page.total;
88
+ pageInput.disabled = busy();
89
+ }
90
+ function snippetDom(snippet) {
91
+ const node = document.createElement("span");
92
+ node.className = "full-search-snippet";
93
+ if (snippet.prefix) node.append(snippet.prefix);
94
+ node.append(snippet.text.slice(0, snippet.matchStart));
95
+ const mark = document.createElement("mark");
96
+ mark.className = "search-match";
97
+ mark.textContent = snippet.text.slice(snippet.matchStart, snippet.matchStart + snippet.matchLength);
98
+ node.append(mark, snippet.text.slice(snippet.matchStart + snippet.matchLength));
99
+ if (snippet.suffix) node.append(snippet.suffix);
100
+ return node;
101
+ }
102
+ function render() {
103
+ updatePagination();
104
+ updateNavigation();
105
+ const offset = state.page?.offset || 0;
106
+ if (renderedOffset === offset && results.childElementCount === search.results.length &&
107
+ renderedResults.length === search.results.length &&
108
+ search.results.every((result, index) => result === renderedResults[index])) return;
109
+ const fragment = document.createDocumentFragment();
110
+ for (const [index, result] of search.results.entries()) {
111
+ const row = document.createElement("button");
112
+ const location = document.createElement("small");
113
+ row.type = "button";
114
+ row.className = "full-search-result";
115
+ location.className = "full-search-location";
116
+ location.textContent = result.location;
117
+ const rank = document.createElement("span");
118
+ rank.className = "full-search-rank";
119
+ rank.textContent = `${offset + index + 1}.`;
120
+ row.append(rank, location, snippetDom(result.snippet));
121
+ row.addEventListener("click", () => activate(index));
122
+ fragment.appendChild(row);
123
+ }
124
+ results.replaceChildren(fragment);
125
+ renderedResults = search.results.slice();
126
+ renderedOffset = offset;
127
+ }
128
+ function resetResults(query = search.query) {
129
+ state.complete = false;
130
+ state.pdfInProgress = state.chapterInProgress = false;
131
+ state.page = state.failedPage = state.pageLoader = null;
132
+ updateSearch({ query, results: [], index: -1 });
133
+ render();
134
+ }
135
+ function cancelPending() {
136
+ clearInputTimer();
137
+ nextGeneration();
138
+ navigation.begin();
139
+ backend.chapterClient()?.cancel();
140
+ backend.pdfClient()?.cancel();
141
+ resetResults();
142
+ }
143
+ function close() {
144
+ clearInputTimer();
145
+ if (busy()) state.complete = false;
146
+ nextGeneration();
147
+ if (state.chapterInProgress) backend.chapterClient()?.cancel();
148
+ if (state.pdfInProgress) backend.pdfClient()?.cancel();
149
+ state.pdfInProgress = state.chapterInProgress = false;
150
+ cancel.hidden = retry.hidden = true;
151
+ panel.hide();
152
+ updateButton(false);
153
+ }
154
+ async function run() {
155
+ if (!active()) return;
156
+ clearInputTimer();
157
+ const query = input.value.trim();
158
+ const generation = nextGeneration();
159
+ backend.pdfClient()?.cancel();
160
+ navigation.begin();
161
+ backend.clearMarks();
162
+ resetResults(query);
163
+ retry.hidden = true;
164
+ retry.textContent = "重试搜索";
165
+ cancel.hidden = !pagedMode() || !query;
166
+ state.pdfInProgress = ["pdf", "pdf-pages", "foliate"].includes(backend.mode()) && !!query;
167
+ state.chapterInProgress = backend.mode() === "epub-chapters" && !!query;
168
+ if (!query) {
169
+ backend.chapterClient()?.cancel();
170
+ status.textContent = "输入关键词搜索正文";
171
+ return;
172
+ }
173
+ status.textContent = "正在搜索正文…";
174
+ try {
175
+ await backend.search(query, generation);
176
+ if (!current(generation)) return;
177
+ state.complete = true;
178
+ state.pdfInProgress = state.chapterInProgress = false;
179
+ render();
180
+ const total = state.page?.total ?? search.results.length;
181
+ status.textContent = total ? `${total}${!state.page && total === 100 ? "+" : ""} 个结果` : "未找到正文匹配";
182
+ } catch (error) {
183
+ if (current(generation)) {
184
+ status.textContent = error.message || "正文搜索不可用";
185
+ retry.hidden = !pagedMode();
186
+ }
187
+ } finally {
188
+ if (current(generation)) {
189
+ state.pdfInProgress = state.chapterInProgress = false;
190
+ updatePagination();
191
+ cancel.hidden = true;
192
+ updateNavigation();
193
+ }
194
+ }
195
+ }
196
+ function applyChapterPage(page) {
197
+ state.page = page;
198
+ updateSearch({ results: page.results.map(backend.chapterResult), index: -1 });
199
+ }
200
+ async function loadPage(offset, selectLast = null) {
201
+ if (!active() || busy() || !state.page || (!backend.chapterClient() && !state.pageLoader)) return;
202
+ state.failedPage = null;
203
+ const generation = nextGeneration();
204
+ navigation.begin();
205
+ if (!["foliate", "epub-chapters"].includes(backend.mode())) cancel.hidden = false;
206
+ retry.hidden = true;
207
+ status.textContent = `${state.page.total} 个结果`;
208
+ try {
209
+ const loader = state.pageLoader;
210
+ const page = await (loader ? loader(offset, generation) : backend.chapterClient().page(offset));
211
+ if (!current(generation)) return;
212
+ if (loader) {
213
+ state.page = page;
214
+ updateSearch({ results: page.results, index: -1 });
215
+ } else applyChapterPage(page);
216
+ render();
217
+ status.textContent = `${page.total} 个结果`;
218
+ if (selectLast !== null) await activate(selectLast ? page.results.length - 1 : 0, false);
219
+ } catch (error) {
220
+ if (current(generation)) {
221
+ status.textContent = error.message || "搜索结果加载失败";
222
+ state.failedPage = { offset, selectLast, query: search.query };
223
+ retry.textContent = "重试本页";
224
+ retry.hidden = false;
225
+ }
226
+ } finally {
227
+ if (current(generation)) cancel.hidden = true;
228
+ }
229
+ }
230
+ async function activate(index, closePanel = true) {
231
+ if (!active()) return;
232
+ const result = search.results[index];
233
+ if (!result) return;
234
+ const previousIndex = search.index;
235
+ const panelSerial = panel.serial();
236
+ const searchGeneration = runtime.currentGeneration("search");
237
+ const generation = navigation.begin();
238
+ const ownsResult = () => current(searchGeneration) && search.results[index] === result;
239
+ updateSearch({ index });
240
+ try {
241
+ const activated = await backend.activate(result, generation);
242
+ if (activated === false && navigation.current(generation) && ownsResult()) {
243
+ updateSearch({ index: previousIndex });
244
+ status.textContent = "搜索结果定位失败,请重试";
245
+ return;
246
+ }
247
+ if (activated === false || !navigation.current(generation) || search.results[index] !== result) return;
248
+ if (status.textContent === "搜索结果定位失败,请重试")
249
+ status.textContent = `${state.page?.total ?? search.results.length} 个结果`;
250
+ if (closePanel && panelSerial === panel.serial() && current(searchGeneration)) panel.close();
251
+ else if (!closePanel) results.children[index]?.scrollIntoView({ block: "nearest" });
252
+ } catch (error) {
253
+ if (error?.name !== "AbortError" && navigation.current(generation) && ownsResult()) {
254
+ updateSearch({ index: previousIndex });
255
+ status.textContent = backend.mode() === "epub-chapters"
256
+ ? error.message || "搜索结果定位失败,请重试" : "搜索结果定位失败,请重试";
257
+ }
258
+ navigation.reportError(error, generation);
259
+ }
260
+ }
261
+ function move(step) {
262
+ const page = state.page;
263
+ if (page?.total) {
264
+ const next = search.index < 0 ? (step > 0 ? 0 : -1) : search.index + step;
265
+ if (next < 0) return loadPage(page.offset ? page.offset - page.pageSize
266
+ : Math.floor((page.total - 1) / page.pageSize) * page.pageSize, true);
267
+ if (next >= search.results.length) return loadPage(
268
+ page.offset + page.pageSize < page.total ? page.offset + page.pageSize : 0, false);
269
+ return activate(next, false);
270
+ }
271
+ if (search.results.length) return activate((search.index + step + search.results.length) % search.results.length, false);
272
+ }
273
+ function toggle() {
274
+ if (!active()) return;
275
+ if (panel.isOpen() && !view.hidden) return close();
276
+ panel.show();
277
+ updateButton(true);
278
+ updateClear();
279
+ input.focus();
280
+ if (search.query !== input.value.trim() || !state.complete) run();
281
+ else if (state.failedPage) retry.hidden = false;
282
+ }
283
+ function queue() {
284
+ if (!active()) return;
285
+ cancelPending();
286
+ cancel.hidden = retry.hidden = true;
287
+ status.textContent = input.value.trim() ? "正在搜索正文…" : "输入关键词搜索正文";
288
+ inputTimer = setTimeout(run, 180);
289
+ }
290
+
291
+ listen(retry, "click", async () => {
292
+ const failed = state.failedPage;
293
+ if (!failed || search.query !== failed.query) return run();
294
+ if (backend.chapterClient()?.failed) {
295
+ await run();
296
+ if (!state.complete || search.query !== failed.query) return;
297
+ }
298
+ return loadPage(failed.offset, failed.selectLast);
299
+ });
300
+ listen(cancel, "click", () => {
301
+ cancelPending();
302
+ cancel.hidden = true;
303
+ retry.hidden = false;
304
+ retry.textContent = "重试搜索";
305
+ status.textContent = "搜索已取消";
306
+ });
307
+ listen(pagination.querySelector("#full-search-page-prev"), "click", () => loadPage(state.page.offset - state.page.pageSize));
308
+ listen(pagination.querySelector("#full-search-page-next"), "click", () => loadPage(state.page.offset + state.page.pageSize));
309
+ listen(pagination.querySelector("#full-search-page"), "change", event => {
310
+ if (!state.page || !Number.isFinite(event.target.valueAsNumber)) return;
311
+ const page = Math.max(1, Math.min(Math.ceil(state.page.total / state.page.pageSize), Math.floor(event.target.valueAsNumber)));
312
+ const offset = (page - 1) * state.page.pageSize;
313
+ if (offset !== state.page.offset) loadPage(offset);
314
+ });
315
+ listen(button, "click", toggle);
316
+ listen(input, "input", event => { updateClear(); if (!event.isComposing) queue(); });
317
+ listen(input, "focus", panel.focus);
318
+ listen(input, "compositionend", queue);
319
+ listen(clear, "click", () => { input.value = ""; updateClear(); run(); input.focus(); });
320
+ listen(view.querySelector("#full-search-prev"), "click", () => move(-1));
321
+ listen(view.querySelector("#full-search-next"), "click", () => move(1));
322
+
323
+ function dispose() {
324
+ if (disposed) return;
325
+ disposed = true;
326
+ clearInputTimer();
327
+ for (const remove of listeners.splice(0)) remove();
328
+ renderedResults = [];
329
+ state.page = state.failedPage = state.pageLoader = null;
330
+ }
331
+ return Object.freeze({
332
+ state, elements: Object.freeze({ view, button, input, status, results, pagination, cancel, retry }),
333
+ render, updatePagination, applyChapterPage, loadPage, run, close, activate, move, dispose
334
+ });
335
+ }
static/reader.css CHANGED
@@ -138,6 +138,11 @@ body {
138
  .icon-button {
139
  width: 32px;
140
  height: 32px;
 
 
 
 
 
141
  border: 0;
142
  background: transparent;
143
  color: inherit;
@@ -147,6 +152,22 @@ body {
147
  cursor: pointer;
148
  border-radius: 4px;
149
  font-size: 18px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  }
151
  .text-button {
152
  height: 32px;
 
138
  .icon-button {
139
  width: 32px;
140
  height: 32px;
141
+ flex: none;
142
+ margin: 0;
143
+ padding: 0;
144
+ appearance: none;
145
+ -webkit-appearance: none;
146
  border: 0;
147
  background: transparent;
148
  color: inherit;
 
152
  cursor: pointer;
153
  border-radius: 4px;
154
  font-size: 18px;
155
+ font-family: inherit;
156
+ line-height: 1;
157
+ }
158
+ .icon-button svg {
159
+ display: block;
160
+ width: 18px;
161
+ height: 18px;
162
+ pointer-events: none;
163
+ }
164
+ .icon-button::-moz-focus-inner {
165
+ padding: 0;
166
+ border: 0;
167
+ }
168
+ .icon-button:focus-visible {
169
+ outline: 2px solid var(--reader-focus-color);
170
+ outline-offset: 1px;
171
  }
172
  .text-button {
173
  height: 32px;
static/reader.html CHANGED
@@ -59,7 +59,12 @@
59
  </head>
60
  <body>
61
  <header class="reader-toolbar">
62
- <button id="back" class="icon-button" type="button">&#8592;</button>
 
 
 
 
 
63
  <div class="reader-heading">
64
  <strong id="title">在线阅读</strong>
65
  <button id="reader-path" class="reader-path" type="button" hidden></button>
@@ -91,8 +96,13 @@
91
  title="下载原文件"
92
  target="_blank"
93
  rel="noopener noreferrer"
94
- >&#8681;</a
95
  >
 
 
 
 
 
 
96
  <span id="download-feedback" role="status" hidden></span>
97
  </div>
98
  <button id="history" class="icon-button" type="button">&#9776;</button>
 
59
  </head>
60
  <body>
61
  <header class="reader-toolbar">
62
+ <button id="back" class="icon-button" type="button" aria-label="返回" title="返回">
63
+ <!-- Lucide arrow-left, ISC; see icons/LICENSE-lucide -->
64
+ <svg class="lucide lucide-arrow-left" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
65
+ <path d="m12 19-7-7 7-7" /><path d="M19 12H5" />
66
+ </svg>
67
+ </button>
68
  <div class="reader-heading">
69
  <strong id="title">在线阅读</strong>
70
  <button id="reader-path" class="reader-path" type="button" hidden></button>
 
96
  title="下载原文件"
97
  target="_blank"
98
  rel="noopener noreferrer"
 
99
  >
100
+ <!-- Lucide download, ISC; see icons/LICENSE-lucide -->
101
+ <svg class="lucide lucide-download" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
102
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
103
+ <polyline points="7 10 12 15 17 10" /><line x1="12" x2="12" y1="15" y2="3" />
104
+ </svg>
105
+ </a>
106
  <span id="download-feedback" role="status" hidden></span>
107
  </div>
108
  <button id="history" class="icon-button" type="button">&#9776;</button>
static/reader.js CHANGED
@@ -1,3 +1,4 @@
 
1
  import "/static/reader-resources.js";
2
  import "/static/reader-contract.js";
3
  import "/static/reader-request-manager.js";
@@ -469,10 +470,7 @@ let chapterManifestLoader = null;
469
  let chapterSearchConfiguration = null;
470
  let chapterSearchModulePromise = null;
471
  let chapterSearchClient = null;
472
- let chapterSearchPage = null;
473
- let failedSearchPage = null;
474
- let chapterSearchInProgress = false;
475
- let fullSearchComplete = false;
476
  trackReaderResource(() => {
477
  chapterSearchClient?.dispose();
478
  chapterSearchClient = null;
@@ -5625,12 +5623,14 @@ function renderMedia(mode) {
5625
  });
5626
  mediaElement = media;
5627
  let sourceRetried = false;
 
 
5628
  media.addEventListener("error", () => {
5629
  if (readerAbortController.signal.aborted) return;
5630
- if (!sourceRetried && sourceUrl !== contentUrl && validSource(sourceUrl)) {
5631
  sourceRetried = true;
5632
  media._readerSourceRetry = true;
5633
- media.src = sourceUrl;
5634
  media.load();
5635
  return;
5636
  }
@@ -6725,100 +6725,47 @@ chapterPrev.addEventListener("click", () =>
6725
  chapterNext.addEventListener("click", () =>
6726
  activateChapter(adjacentTocIndex(1))
6727
  );
6728
- const fullSearchView = document.createElement("section");
6729
- fullSearchView.id = "full-search-view";
6730
- fullSearchView.className = "reader-panel-view full-search-view";
6731
- fullSearchView.dataset.panelView = "full-search";
6732
- fullSearchView.hidden = true;
6733
- fullSearchView.innerHTML =
6734
- '<div class="full-search-bar"><input id="full-search-input" class="full-search-input" type="search" placeholder="搜索正文" aria-label="搜索正文"><button id="full-search-clear" class="icon-button" type="button" aria-label="清除全文搜索" title="清除">×</button></div><div id="full-search-status" class="full-search-status" role="status">输入关键词搜索正文</div><div class="full-search-nav"><button id="full-search-prev" type="button" disabled>上一个</button><button id="full-search-next" type="button" disabled>下一个</button></div><div id="full-search-results" class="full-search-results"></div>';
6735
- document
6736
- .querySelector("#history-panel")
6737
- .insertBefore(fullSearchView, document.querySelector(".reader-panel-tabs"));
6738
- const fullSearchButton = document.querySelector("#full-search-toggle");
6739
- fullSearchButton.textContent = "全文搜索";
6740
- fullSearchButton.setAttribute("aria-label", "全文搜索");
6741
- fullSearchButton.hidden = !capability.features.search || (capability.mode === "pdf-pages" && !ocrManifestUrl && !pdfHybridUrl);
6742
- const fullSearchInput = fullSearchView.querySelector("#full-search-input"),
6743
- fullSearchStatus = fullSearchView.querySelector("#full-search-status"),
6744
- fullSearchResultsNode = fullSearchView.querySelector("#full-search-results"),
6745
- fullSearchClear = fullSearchView.querySelector("#full-search-clear");
6746
- let fullSearchInputTimer = 0;
6747
- fullSearchClear.hidden = true;
6748
- function updateFullSearchButton(open) {
6749
- fullSearchButton.textContent = open ? "关闭全文搜索" : "全文搜索";
6750
- fullSearchButton.setAttribute("aria-label", fullSearchButton.textContent);
6751
- }
6752
- function updateFullSearchClear() {
6753
- fullSearchClear.hidden = !fullSearchInput.value;
6754
- }
6755
- const chapterSearchPagination = document.createElement("div");
6756
- chapterSearchPagination.className = "full-search-pagination";
6757
- chapterSearchPagination.hidden = true;
6758
- chapterSearchPagination.innerHTML =
6759
- '<button id="full-search-page-prev" type="button">上一页</button><label>第 <input id="full-search-page" type="number" min="1" value="1" aria-label="搜索结果页码"> 页 <span id="full-search-pages"></span></label><button id="full-search-page-next" type="button">下一页</button>';
6760
- fullSearchResultsNode.before(chapterSearchPagination);
6761
- const fullSearchCancel = document.createElement("button");
6762
- fullSearchCancel.type = "button";
6763
- fullSearchCancel.id = "full-search-cancel";
6764
- fullSearchCancel.className = "text-action";
6765
- fullSearchCancel.textContent = "取消搜索";
6766
- fullSearchCancel.hidden = true;
6767
- fullSearchStatus.after(fullSearchCancel);
6768
- const fullSearchRetry = document.createElement("button");
6769
- fullSearchRetry.type = "button";
6770
- fullSearchRetry.id = "full-search-retry";
6771
- fullSearchRetry.className = "text-action";
6772
- fullSearchRetry.textContent = "重试搜索";
6773
- fullSearchRetry.hidden = true;
6774
- fullSearchCancel.after(fullSearchRetry);
6775
- fullSearchRetry.addEventListener("click", async () => {
6776
- const failed = failedSearchPage;
6777
- if (!failed || searchState.query !== failed.query) return runFullSearch();
6778
- if (chapterSearchClient?.failed) {
6779
- await runFullSearch();
6780
- if (!fullSearchComplete || searchState.query !== failed.query) return;
6781
  }
6782
- return loadChapterSearchPage(failed.offset, failed.selectLast);
6783
- });
6784
- fullSearchCancel.addEventListener("click", () => {
6785
- nextReaderGeneration("search");
6786
- beginReaderNavigation();
6787
- chapterSearchClient?.cancel();
6788
- pdfBookSearchClient?.cancel();
6789
- pdfSearchInProgress = false;
6790
- chapterSearchInProgress = false;
6791
- chapterSearchPage = null;
6792
- failedSearchPage = null;
6793
- pdfSearchPageLoader = null;
6794
- fullSearchComplete = false;
6795
- updateSearchState({ results: [], index: -1 });
6796
- renderFullSearchResults();
6797
- fullSearchCancel.hidden = true;
6798
- fullSearchRetry.hidden = false;
6799
- fullSearchRetry.textContent = "重试搜索";
6800
- fullSearchStatus.textContent = "搜索已取消";
6801
- });
6802
- chapterSearchPagination
6803
- .querySelector("#full-search-page-prev")
6804
- .addEventListener("click", () =>
6805
- loadChapterSearchPage(chapterSearchPage.offset - chapterSearchPage.pageSize)
6806
- );
6807
- chapterSearchPagination
6808
- .querySelector("#full-search-page-next")
6809
- .addEventListener("click", () =>
6810
- loadChapterSearchPage(chapterSearchPage.offset + chapterSearchPage.pageSize)
6811
- );
6812
- chapterSearchPagination.querySelector("#full-search-page").addEventListener("change", (event) => {
6813
- if (!chapterSearchPage || !Number.isFinite(event.target.valueAsNumber)) return;
6814
- const page = Math.max(1, Math.min(
6815
- Math.ceil(chapterSearchPage.total / chapterSearchPage.pageSize),
6816
- Math.floor(event.target.valueAsNumber)
6817
- ));
6818
- const offset = (page - 1) * chapterSearchPage.pageSize;
6819
- if (offset !== chapterSearchPage.offset) loadChapterSearchPage(offset);
6820
  });
6821
- // Full-text search: format matching, result presentation, and navigation.
 
 
 
 
 
 
 
 
 
 
6822
  function fullSearchPageOffset(total, requestedOffset, pageSize = 50) {
6823
  return Math.max(0, Math.min(
6824
  Math.floor(Math.max(0, total - 1) / pageSize),
@@ -6853,16 +6800,7 @@ async function searchChapterBook(query, generation) {
6853
  });
6854
  if (isReaderGenerationCurrent("search", generation)) applyChapterSearchPage(page);
6855
  }
6856
- function applyChapterSearchPage(page) {
6857
- chapterSearchPage = page;
6858
- updateSearchState({
6859
- results: page.results.map((result) => ({
6860
- ...result,
6861
- activate: (generation) => navigateChapterSearchResult(result, generation)
6862
- })),
6863
- index: -1
6864
- });
6865
- }
6866
  async function navigateChapterSearchResult(result, generation) {
6867
  const article = await chapterManifestLoader?.(result.chapterIndex);
6868
  if (!article || !isReaderGenerationCurrent("navigation", generation)) return false;
@@ -6907,54 +6845,7 @@ async function navigateChapterSearchResult(result, generation) {
6907
  scheduleSave();
6908
  return true;
6909
  }
6910
- function updateChapterSearchPagination() {
6911
- const page = chapterSearchPage;
6912
- chapterSearchPagination.hidden = !page || !page.total;
6913
- if (!page) return;
6914
- const pages = Math.ceil(page.total / page.pageSize);
6915
- const input = chapterSearchPagination.querySelector("#full-search-page");
6916
- input.value = String(Math.floor(page.offset / page.pageSize) + 1);
6917
- input.max = String(Math.max(1, pages));
6918
- input.style.setProperty("--page-width", `${input.max.length}ch`);
6919
- chapterSearchPagination.querySelector("#full-search-pages").textContent = `/ ${pages}`;
6920
- chapterSearchPagination.querySelector("#full-search-page-prev").disabled =
6921
- pdfSearchInProgress || chapterSearchInProgress || page.offset === 0;
6922
- chapterSearchPagination.querySelector("#full-search-page-next").disabled =
6923
- pdfSearchInProgress || chapterSearchInProgress || page.offset + page.pageSize >= page.total;
6924
- input.disabled = pdfSearchInProgress || chapterSearchInProgress;
6925
- }
6926
- async function loadChapterSearchPage(offset, selectLast = null) {
6927
- if (pdfSearchInProgress || chapterSearchInProgress || !chapterSearchPage ||
6928
- (!chapterSearchClient && !pdfSearchPageLoader)) return;
6929
- failedSearchPage = null;
6930
- const generation = nextReaderGeneration("search");
6931
- beginReaderNavigation();
6932
- if (!["foliate", "epub-chapters"].includes(capability.mode)) fullSearchCancel.hidden = false;
6933
- fullSearchRetry.hidden = true;
6934
- fullSearchStatus.textContent = `${chapterSearchPage.total} 个结果`;
6935
- try {
6936
- const pdfLoader = pdfSearchPageLoader;
6937
- const page = await (pdfLoader ? pdfLoader(offset, generation) : chapterSearchClient.page(offset));
6938
- if (!isReaderGenerationCurrent("search", generation)) return;
6939
- if (pdfLoader) {
6940
- chapterSearchPage = page;
6941
- updateSearchState({ results: page.results, index: -1 });
6942
- } else applyChapterSearchPage(page);
6943
- renderFullSearchResults();
6944
- fullSearchStatus.textContent = `${page.total} 个结果`;
6945
- if (selectLast !== null)
6946
- await activateFullSearchResult(selectLast ? page.results.length - 1 : 0, false);
6947
- } catch (error) {
6948
- if (isReaderGenerationCurrent("search", generation)) {
6949
- fullSearchStatus.textContent = error.message || "搜索结果加载失败";
6950
- failedSearchPage = { offset, selectLast, query: searchState.query };
6951
- fullSearchRetry.textContent = "重试本页";
6952
- fullSearchRetry.hidden = false;
6953
- }
6954
- } finally {
6955
- if (isReaderGenerationCurrent("search", generation)) fullSearchCancel.hidden = true;
6956
- }
6957
- }
6958
  async function fullSearchFoliateMatches(query, generation) {
6959
  if (!epubRendition?.search) return [];
6960
  const groups = [], firstPage = [];
@@ -6976,7 +6867,7 @@ async function fullSearchFoliateMatches(query, generation) {
6976
  }
6977
  };
6978
  };
6979
- pdfSearchPageLoader = async (requestedOffset, pageGeneration = readerRuntime.currentGeneration("search")) => {
6980
  const pageSize = 50;
6981
  const offset = fullSearchPageOffset(total, requestedOffset, pageSize);
6982
  if (!offset) return { total, offset, pageSize, results: firstPage.slice() };
@@ -7019,14 +6910,14 @@ async function fullSearchFoliateMatches(query, generation) {
7019
  firstPage.push(toResult(item, group.label, index));
7020
  }
7021
  total += group.count;
7022
- chapterSearchPage = { total, offset: 0, pageSize: 50, results: firstPage.slice() };
7023
- updateSearchState({ results: chapterSearchPage.results, index: -1 });
7024
  renderFullSearchResults();
7025
  fullSearchStatus.textContent = `${total} 个结果(正在搜索…)`;
7026
  await waitForReader();
7027
  if (!isReaderGenerationCurrent("search", generation)) return [];
7028
  }
7029
- chapterSearchPage = { total, offset: 0, pageSize: 50, results: firstPage.slice() };
7030
  return firstPage;
7031
  }
7032
  function fullSearchEscape(value) {
@@ -7213,21 +7104,7 @@ function fullSearchSnippet(text, index, length) {
7213
  suffix: end < text.length ? "…" : ""
7214
  };
7215
  }
7216
- function fullSearchSnippetDom(snippet) {
7217
- const node = document.createElement("span");
7218
- node.className = "full-search-snippet";
7219
- if (snippet.prefix) node.append(snippet.prefix);
7220
- node.append(snippet.text.slice(0, snippet.matchStart));
7221
- const mark = document.createElement("mark");
7222
- mark.className = "search-match";
7223
- mark.textContent = snippet.text.slice(
7224
- snippet.matchStart,
7225
- snippet.matchStart + snippet.matchLength
7226
- );
7227
- node.append(mark, snippet.text.slice(snippet.matchStart + snippet.matchLength));
7228
- if (snippet.suffix) node.append(snippet.suffix);
7229
- return node;
7230
- }
7231
  function createFullSearchHeadingLookup(root, offsets) {
7232
  const entries = navigationState.tocEntries, boundaries = [];
7233
  const headings = entries.filter(entry => entry.target);
@@ -7389,9 +7266,9 @@ async function fullSearchDomMatches(root, fallback, query, generation, progressi
7389
  groups.push(group);
7390
  total += group.count;
7391
  if (progressive && (start === 0 || (group.count && total <= 50) || groups.length % 8 === 0)) {
7392
- pdfSearchPageLoader = page;
7393
- chapterSearchPage = page(0);
7394
- updateSearchState({ results: chapterSearchPage.results, index: -1 });
7395
  renderFullSearchResults();
7396
  fullSearchStatus.textContent = `${total} 个结果(正在搜索…)`;
7397
  }
@@ -7399,8 +7276,8 @@ async function fullSearchDomMatches(root, fallback, query, generation, progressi
7399
  if (!isReaderGenerationCurrent("search", generation)) return [];
7400
  }
7401
  if (progressive) {
7402
- pdfSearchPageLoader = page;
7403
- chapterSearchPage = page(0);
7404
  }
7405
  if (occurrence !== null) {
7406
  if (!isReaderGenerationCurrent("navigation", navigationGeneration)) return [];
@@ -7494,8 +7371,7 @@ async function navigateFoliateSearchResult(result, generation) {
7494
  foliateScrollAnchors.remember();
7495
  return true;
7496
  }
7497
- let pdfSearchPageLoader = null;
7498
- let pdfSearchInProgress = false;
7499
  async function ensurePdfBookSearchClient(manifest) {
7500
  let textUrl = "";
7501
  let partitioned = null;
@@ -7590,19 +7466,19 @@ async function searchConcentratedPdf(query, generation) {
7590
  if (page.offset === 0) firstPageResults = results;
7591
  return { ...page, results };
7592
  };
7593
- pdfSearchPageLoader = async offset => attach(await client.page(offset));
7594
  const page = await client.search(query, partial => {
7595
  if (!isReaderGenerationCurrent("search", generation)) return;
7596
- chapterSearchPage = attach(partial);
7597
- updateSearchState({ results: chapterSearchPage.results, index: -1 });
7598
  renderFullSearchResults();
7599
  fullSearchStatus.textContent = partial.total
7600
  ? `${partial.total} 个结果(正在搜索… ${partial.scanned} / ${partial.pages} 页)`
7601
  : `正在搜索正文… ${partial.scanned} / ${partial.pages} 页`;
7602
  });
7603
  if (!isReaderGenerationCurrent("search", generation)) return [];
7604
- chapterSearchPage = attach(page);
7605
- return chapterSearchPage.results;
7606
  }
7607
 
7608
  function makePdfSearchPageLoader(groups, total, pattern, firstPage) {
@@ -7641,9 +7517,9 @@ function makePdfSearchPageLoader(groups, total, pattern, firstPage) {
7641
 
7642
  function publishPdfSearchProgress(generation, groups, total, pattern, firstPage, scanned, pages) {
7643
  if (!isReaderGenerationCurrent("search", generation)) return false;
7644
- pdfSearchPageLoader = makePdfSearchPageLoader(groups, total, pattern, firstPage);
7645
- chapterSearchPage = { total, offset: 0, pageSize: 50, results: firstPage.slice() };
7646
- updateSearchState({ results: chapterSearchPage.results, index: -1 });
7647
  renderFullSearchResults();
7648
  fullSearchStatus.textContent = total
7649
  ? `${total} 个结果(正在搜索… ${scanned} / ${pages} 页)`
@@ -7776,261 +7652,40 @@ async function fullSearchPdfMatches(query, generation) {
7776
  }
7777
  if (!hasText) throw new Error("此 PDF 尚无可搜索文字层,需要完成 OCR 后才能搜索正文");
7778
  publishPdfSearchProgress(generation, groups, total, pattern, firstPage, pdf.numPages, pdf.numPages);
7779
- return chapterSearchPage.results;
7780
- }
7781
- let fullSearchRenderedResults = [];
7782
- let fullSearchRenderedOffset = -1;
7783
- function renderFullSearchResults() {
7784
- updateChapterSearchPagination();
7785
- fullSearchView.querySelectorAll(".full-search-nav button").forEach((button) => {
7786
- button.disabled = !searchState.results.length;
7787
- });
7788
- const offset = chapterSearchPage?.offset || 0;
7789
- if (fullSearchRenderedOffset === offset &&
7790
- fullSearchResultsNode.childElementCount === searchState.results.length &&
7791
- fullSearchRenderedResults.length === searchState.results.length &&
7792
- searchState.results.every((result, index) => result === fullSearchRenderedResults[index])) return;
7793
- const fragment = document.createDocumentFragment();
7794
- for (const [index, result] of searchState.results.entries()) {
7795
- const row = document.createElement("button"),
7796
- location = document.createElement("small");
7797
- row.type = "button";
7798
- row.className = "full-search-result";
7799
- location.className = "full-search-location";
7800
- location.textContent = result.location;
7801
- const rank = document.createElement("span");
7802
- rank.className = "full-search-rank";
7803
- rank.textContent = `${offset + index + 1}.`;
7804
- row.append(rank, location, fullSearchSnippetDom(result.snippet));
7805
- row.addEventListener("click", () => activateFullSearchResult(index));
7806
- fragment.appendChild(row);
7807
- }
7808
- fullSearchResultsNode.replaceChildren(fragment);
7809
- fullSearchRenderedResults = searchState.results.slice();
7810
- fullSearchRenderedOffset = offset;
7811
- }
7812
- function closeFullSearchView() {
7813
- clearTimeout(fullSearchInputTimer);
7814
- fullSearchInputTimer = 0;
7815
- if (pdfSearchInProgress || chapterSearchInProgress) fullSearchComplete = false;
7816
- nextReaderGeneration("search");
7817
- if (chapterSearchInProgress) chapterSearchClient?.cancel();
7818
- if (pdfSearchInProgress) pdfBookSearchClient?.cancel();
7819
- pdfSearchInProgress = false;
7820
- chapterSearchInProgress = false;
7821
- fullSearchCancel.hidden = true;
7822
- fullSearchRetry.hidden = true;
7823
- selectPanel(mediaElement ? "media" : "toc");
7824
- updateFullSearchButton(false);
7825
- }
7826
- async function runFullSearch() {
7827
- clearTimeout(fullSearchInputTimer);
7828
- fullSearchInputTimer = 0;
7829
- const query = fullSearchInput.value.trim();
7830
- fullSearchComplete = false;
7831
- failedSearchPage = null;
7832
- const generation = nextReaderGeneration("search");
7833
- pdfBookSearchClient?.cancel();
7834
- beginReaderNavigation();
7835
- clearFullSearchMarks();
7836
- updateSearchState({ query, results: [], index: -1 });
7837
- chapterSearchPage = null;
7838
- pdfSearchPageLoader = null;
7839
- updateChapterSearchPagination();
7840
- fullSearchRetry.hidden = true;
7841
- fullSearchRetry.textContent = "重试搜索";
7842
- fullSearchCancel.hidden = !["epub-chapters", "pdf", "pdf-pages", "foliate"].includes(capability.mode) || !query;
7843
- pdfSearchInProgress = ["pdf", "pdf-pages", "foliate"].includes(capability.mode) && !!query;
7844
- chapterSearchInProgress = capability.mode === "epub-chapters" && !!query;
7845
- fullSearchResultsNode.textContent = "";
7846
- fullSearchView.querySelectorAll(".full-search-nav button").forEach((button) => {
7847
- button.disabled = true;
7848
- });
7849
- if (!query) {
7850
- chapterSearchClient?.cancel();
7851
- fullSearchStatus.textContent = "输入关键词搜索正文";
7852
- return;
7853
- }
7854
- fullSearchStatus.textContent = "正在搜索正文…";
7855
- try {
7856
- if ((capability.mode === "pdf" || pdfHybridUrl) && !ocrManifestUrl) {
7857
- if (pdfHybridUrl) await pdfHybridDocumentPromise;
7858
- await fullSearchPdfMatches(query, generation);
7859
- }
7860
- else if (["pdf", "pdf-pages"].includes(capability.mode))
7861
- publishSearchResults(generation, await searchConcentratedPdf(query, generation));
7862
- else if (capability.mode === "foliate")
7863
- publishSearchResults(generation, await fullSearchFoliateMatches(query, generation));
7864
- else if (capability.mode === "epub-chapters") await searchChapterBook(query, generation);
7865
- else if (["text", "markdown", "docx"].includes(capability.mode))
7866
- publishSearchResults(
7867
- generation,
7868
- await fullSearchDomMatches(content, "阅读位置", query, generation, true)
7869
- );
7870
- else if (capability.mode === "html")
7871
- publishSearchResults(
7872
- generation,
7873
- htmlFrame?.contentDocument?.body
7874
- ? await fullSearchDomMatches(
7875
- htmlFrame.contentDocument.body,
7876
- "HTML 阅读位置",
7877
- query,
7878
- generation,
7879
- true
7880
- )
7881
- : []
7882
- );
7883
- else throw new Error("此格式没有可搜索文本");
7884
- if (!isReaderGenerationCurrent("search", generation)) return;
7885
- fullSearchComplete = true;
7886
- pdfSearchInProgress = false;
7887
- chapterSearchInProgress = false;
7888
- renderFullSearchResults();
7889
- fullSearchStatus.textContent = chapterSearchPage
7890
- ? chapterSearchPage.total
7891
- ? `${chapterSearchPage.total} 个结果`
7892
- : "未找到正文匹配"
7893
- : searchState.results.length
7894
- ? `${searchState.results.length}${searchState.results.length === 100 ? "+" : ""} 个结果`
7895
- : "未找到正文匹配";
7896
- } catch (error) {
7897
- if (isReaderGenerationCurrent("search", generation)) {
7898
- pdfSearchInProgress = false;
7899
- chapterSearchInProgress = false;
7900
- fullSearchStatus.textContent = error.message || "正文搜索不可用";
7901
- fullSearchRetry.hidden = !["epub-chapters", "pdf", "pdf-pages", "foliate"].includes(capability.mode);
7902
- }
7903
- } finally {
7904
- if (isReaderGenerationCurrent("search", generation))
7905
- pdfSearchInProgress = false;
7906
- if (isReaderGenerationCurrent("search", generation)) {
7907
- chapterSearchInProgress = false;
7908
- updateChapterSearchPagination();
7909
- }
7910
- if (isReaderGenerationCurrent("search", generation)) fullSearchCancel.hidden = true;
7911
- if (isReaderGenerationCurrent("search", generation))
7912
- fullSearchView.querySelectorAll(".full-search-nav button").forEach((button) => {
7913
- button.disabled = !searchState.results.length;
7914
- });
7915
- }
7916
  }
7917
- async function activateFullSearchResult(index, closePanel = true) {
7918
- const result = searchState.results[index];
7919
- if (!result) return;
7920
- const previousIndex = searchState.index;
7921
- const panelSerial = readerPanelInteractionSerial;
7922
- const searchGeneration = readerRuntime.currentGeneration("search");
7923
- const generation = beginReaderNavigation();
7924
- updateSearchState({ index });
7925
- try {
7926
- const activated = result.cfi
7927
- ? await navigateFoliateSearchResult(result, generation)
7928
- : await result.activate(generation);
7929
- if (activated === false && isReaderGenerationCurrent("navigation", generation) &&
7930
- isReaderGenerationCurrent("search", searchGeneration) && searchState.results[index] === result) {
7931
- updateSearchState({ index: previousIndex });
7932
- fullSearchStatus.textContent = "搜索结果定位失败,请重试";
7933
- return;
7934
- }
7935
- if (
7936
- activated === false ||
7937
- !isReaderGenerationCurrent("navigation", generation) ||
7938
- searchState.results[index] !== result
7939
- )
7940
- return;
7941
- if (fullSearchStatus.textContent === "搜索结果定位失败,请重试")
7942
- fullSearchStatus.textContent = `${chapterSearchPage?.total ?? searchState.results.length} 个结果`;
7943
- if (closePanel && panelSerial === readerPanelInteractionSerial &&
7944
- isReaderGenerationCurrent("search", searchGeneration))
7945
- setReaderPanelOpen(false, true);
7946
- else if (!closePanel) fullSearchResultsNode.children[index]?.scrollIntoView({ block: "nearest" });
7947
- } catch (error) {
7948
- if (error?.name !== "AbortError" && isReaderGenerationCurrent("navigation", generation) &&
7949
- isReaderGenerationCurrent("search", searchGeneration) && searchState.results[index] === result) {
7950
- updateSearchState({ index: previousIndex });
7951
- fullSearchStatus.textContent = capability.mode === "epub-chapters"
7952
- ? error.message || "搜索结果定位失败,请重试"
7953
- : "搜索结果定位失败,请重试";
7954
- }
7955
- reportNavigationError(error, generation);
7956
- }
7957
- }
7958
- function moveFullSearch(step) {
7959
- if (chapterSearchPage?.total) {
7960
- const page = chapterSearchPage;
7961
- const next = searchState.index < 0 ? (step > 0 ? 0 : -1) : searchState.index + step;
7962
- if (next < 0)
7963
- return loadChapterSearchPage(
7964
- page.offset
7965
- ? page.offset - page.pageSize
7966
- : Math.floor((page.total - 1) / page.pageSize) * page.pageSize,
7967
- true
7968
- );
7969
- if (next >= searchState.results.length)
7970
- return loadChapterSearchPage(
7971
- page.offset + page.pageSize < page.total ? page.offset + page.pageSize : 0,
7972
- false
7973
- );
7974
- return activateFullSearchResult(next, false);
7975
- }
7976
- if (searchState.results.length)
7977
- return activateFullSearchResult(
7978
- (searchState.index + step + searchState.results.length) % searchState.results.length,
7979
- false
7980
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7981
  }
7982
- function toggleFullSearch() {
7983
- const panel = document.querySelector("#history-panel");
7984
- if (panel.classList.contains("is-open") && !fullSearchView.hidden) {
7985
- closeFullSearchView();
7986
- return;
7987
- }
7988
- setReaderPanelOpen(true);
7989
- selectPanel("full-search");
7990
- updateFullSearchButton(true);
7991
- updateFullSearchClear();
7992
- fullSearchInput.focus();
7993
- if (searchState.query !== fullSearchInput.value.trim() || !fullSearchComplete) runFullSearch();
7994
- else if (failedSearchPage) fullSearchRetry.hidden = false;
7995
- }
7996
- fullSearchButton.addEventListener("click", toggleFullSearch);
7997
- function queueFullSearch() {
7998
- clearTimeout(fullSearchInputTimer);
7999
- nextReaderGeneration("search");
8000
- beginReaderNavigation();
8001
- chapterSearchClient?.cancel();
8002
- pdfBookSearchClient?.cancel();
8003
- fullSearchComplete = false;
8004
- pdfSearchInProgress = false;
8005
- chapterSearchInProgress = false;
8006
- chapterSearchPage = null;
8007
- failedSearchPage = null;
8008
- pdfSearchPageLoader = null;
8009
- updateSearchState({ results: [], index: -1 });
8010
- renderFullSearchResults();
8011
- fullSearchCancel.hidden = true;
8012
- fullSearchRetry.hidden = true;
8013
- fullSearchStatus.textContent = fullSearchInput.value.trim() ? "正在搜索正文…" : "输入关键词搜索正文";
8014
- fullSearchInputTimer = setTimeout(runFullSearch, 180);
8015
- }
8016
- fullSearchInput.addEventListener("input", (event) => {
8017
- updateFullSearchClear();
8018
- if (!event.isComposing) queueFullSearch();
8019
- });
8020
- fullSearchInput.addEventListener("focus", () => { readerPanelInteractionSerial++; });
8021
- fullSearchInput.addEventListener("compositionend", queueFullSearch);
8022
- fullSearchView.querySelector("#full-search-clear").addEventListener("click", () => {
8023
- fullSearchInput.value = "";
8024
- updateFullSearchClear();
8025
- runFullSearch();
8026
- fullSearchInput.focus();
8027
- });
8028
- fullSearchView
8029
- .querySelector("#full-search-prev")
8030
- .addEventListener("click", () => moveFullSearch(-1));
8031
- fullSearchView
8032
- .querySelector("#full-search-next")
8033
- .addEventListener("click", () => moveFullSearch(1));
8034
  function syncCapabilityControls() {
8035
  content.dataset.mode = capability.mode || "unsupported";
8036
  pageInvertToggle.hidden = !["pdf", "pdf-pages"].includes(capability.mode);
 
1
+ import { createReaderSearchController } from "./reader-search-controller.mjs";
2
  import "/static/reader-resources.js";
3
  import "/static/reader-contract.js";
4
  import "/static/reader-request-manager.js";
 
470
  let chapterSearchConfiguration = null;
471
  let chapterSearchModulePromise = null;
472
  let chapterSearchClient = null;
473
+
 
 
 
474
  trackReaderResource(() => {
475
  chapterSearchClient?.dispose();
476
  chapterSearchClient = null;
 
5623
  });
5624
  mediaElement = media;
5625
  let sourceRetried = false;
5626
+ const originalMode = VoiceOfMLReader.capability(resolvedReaderData?.original_extension || extension).mode;
5627
+ const retryUrl = originalMode === mode && validSource(downloadUrl) ? downloadUrl : sourceUrl;
5628
  media.addEventListener("error", () => {
5629
  if (readerAbortController.signal.aborted) return;
5630
+ if (!sourceRetried && retryUrl !== media.src && validSource(retryUrl)) {
5631
  sourceRetried = true;
5632
  media._readerSourceRetry = true;
5633
+ media.src = retryUrl;
5634
  media.load();
5635
  return;
5636
  }
 
6725
  chapterNext.addEventListener("click", () =>
6726
  activateChapter(adjacentTocIndex(1))
6727
  );
6728
+ const fullSearchController = createReaderSearchController({
6729
+ document, runtime: readerRuntime, signal: readerAbortController.signal,
6730
+ nextGeneration: () => nextReaderGeneration("search"),
6731
+ isCurrent: generation => isReaderGenerationCurrent("search", generation),
6732
+ updateSearch: updateSearchState,
6733
+ backend: {
6734
+ mode: () => capability.mode,
6735
+ search: searchReaderText,
6736
+ clearMarks: clearFullSearchMarks,
6737
+ chapterClient: () => chapterSearchClient,
6738
+ pdfClient: () => pdfBookSearchClient,
6739
+ chapterResult: result => ({ ...result,
6740
+ activate: generation => navigateChapterSearchResult(result, generation) }),
6741
+ activate: (result, generation) => result.cfi
6742
+ ? navigateFoliateSearchResult(result, generation) : result.activate(generation)
6743
+ },
6744
+ navigation: {
6745
+ begin: beginReaderNavigation,
6746
+ current: generation => isReaderGenerationCurrent("navigation", generation),
6747
+ reportError: reportNavigationError
6748
+ },
6749
+ panel: {
6750
+ isOpen: () => document.querySelector("#history-panel").classList.contains("is-open"),
6751
+ serial: () => readerPanelInteractionSerial,
6752
+ show: () => { setReaderPanelOpen(true); selectPanel("full-search"); },
6753
+ hide: () => selectPanel(mediaElement ? "media" : "toc"),
6754
+ close: () => setReaderPanelOpen(false, true),
6755
+ focus: () => { readerPanelInteractionSerial++; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6756
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6757
  });
6758
+ trackReaderResource(fullSearchController.dispose);
6759
+ const fullSearchSession = fullSearchController.state;
6760
+ const {
6761
+ render: renderFullSearchResults, applyChapterPage: applyChapterSearchPage,
6762
+ run: runFullSearch, close: closeFullSearchView
6763
+ } = fullSearchController;
6764
+ const {
6765
+ view: fullSearchView, button: fullSearchButton, input: fullSearchInput,
6766
+ status: fullSearchStatus
6767
+ } = fullSearchController.elements;
6768
+ // Format-specific search backends publish through the shared controller.
6769
  function fullSearchPageOffset(total, requestedOffset, pageSize = 50) {
6770
  return Math.max(0, Math.min(
6771
  Math.floor(Math.max(0, total - 1) / pageSize),
 
6800
  });
6801
  if (isReaderGenerationCurrent("search", generation)) applyChapterSearchPage(page);
6802
  }
6803
+
 
 
 
 
 
 
 
 
 
6804
  async function navigateChapterSearchResult(result, generation) {
6805
  const article = await chapterManifestLoader?.(result.chapterIndex);
6806
  if (!article || !isReaderGenerationCurrent("navigation", generation)) return false;
 
6845
  scheduleSave();
6846
  return true;
6847
  }
6848
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6849
  async function fullSearchFoliateMatches(query, generation) {
6850
  if (!epubRendition?.search) return [];
6851
  const groups = [], firstPage = [];
 
6867
  }
6868
  };
6869
  };
6870
+ fullSearchSession.pageLoader = async (requestedOffset, pageGeneration = readerRuntime.currentGeneration("search")) => {
6871
  const pageSize = 50;
6872
  const offset = fullSearchPageOffset(total, requestedOffset, pageSize);
6873
  if (!offset) return { total, offset, pageSize, results: firstPage.slice() };
 
6910
  firstPage.push(toResult(item, group.label, index));
6911
  }
6912
  total += group.count;
6913
+ fullSearchSession.page = { total, offset: 0, pageSize: 50, results: firstPage.slice() };
6914
+ updateSearchState({ results: fullSearchSession.page.results, index: -1 });
6915
  renderFullSearchResults();
6916
  fullSearchStatus.textContent = `${total} 个结果(正在搜索…)`;
6917
  await waitForReader();
6918
  if (!isReaderGenerationCurrent("search", generation)) return [];
6919
  }
6920
+ fullSearchSession.page = { total, offset: 0, pageSize: 50, results: firstPage.slice() };
6921
  return firstPage;
6922
  }
6923
  function fullSearchEscape(value) {
 
7104
  suffix: end < text.length ? "…" : ""
7105
  };
7106
  }
7107
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7108
  function createFullSearchHeadingLookup(root, offsets) {
7109
  const entries = navigationState.tocEntries, boundaries = [];
7110
  const headings = entries.filter(entry => entry.target);
 
7266
  groups.push(group);
7267
  total += group.count;
7268
  if (progressive && (start === 0 || (group.count && total <= 50) || groups.length % 8 === 0)) {
7269
+ fullSearchSession.pageLoader = page;
7270
+ fullSearchSession.page = page(0);
7271
+ updateSearchState({ results: fullSearchSession.page.results, index: -1 });
7272
  renderFullSearchResults();
7273
  fullSearchStatus.textContent = `${total} 个结果(正在搜索…)`;
7274
  }
 
7276
  if (!isReaderGenerationCurrent("search", generation)) return [];
7277
  }
7278
  if (progressive) {
7279
+ fullSearchSession.pageLoader = page;
7280
+ fullSearchSession.page = page(0);
7281
  }
7282
  if (occurrence !== null) {
7283
  if (!isReaderGenerationCurrent("navigation", navigationGeneration)) return [];
 
7371
  foliateScrollAnchors.remember();
7372
  return true;
7373
  }
7374
+
 
7375
  async function ensurePdfBookSearchClient(manifest) {
7376
  let textUrl = "";
7377
  let partitioned = null;
 
7466
  if (page.offset === 0) firstPageResults = results;
7467
  return { ...page, results };
7468
  };
7469
+ fullSearchSession.pageLoader = async offset => attach(await client.page(offset));
7470
  const page = await client.search(query, partial => {
7471
  if (!isReaderGenerationCurrent("search", generation)) return;
7472
+ fullSearchSession.page = attach(partial);
7473
+ updateSearchState({ results: fullSearchSession.page.results, index: -1 });
7474
  renderFullSearchResults();
7475
  fullSearchStatus.textContent = partial.total
7476
  ? `${partial.total} 个结果(正在搜索… ${partial.scanned} / ${partial.pages} 页)`
7477
  : `正在搜索正文… ${partial.scanned} / ${partial.pages} 页`;
7478
  });
7479
  if (!isReaderGenerationCurrent("search", generation)) return [];
7480
+ fullSearchSession.page = attach(page);
7481
+ return fullSearchSession.page.results;
7482
  }
7483
 
7484
  function makePdfSearchPageLoader(groups, total, pattern, firstPage) {
 
7517
 
7518
  function publishPdfSearchProgress(generation, groups, total, pattern, firstPage, scanned, pages) {
7519
  if (!isReaderGenerationCurrent("search", generation)) return false;
7520
+ fullSearchSession.pageLoader = makePdfSearchPageLoader(groups, total, pattern, firstPage);
7521
+ fullSearchSession.page = { total, offset: 0, pageSize: 50, results: firstPage.slice() };
7522
+ updateSearchState({ results: fullSearchSession.page.results, index: -1 });
7523
  renderFullSearchResults();
7524
  fullSearchStatus.textContent = total
7525
  ? `${total} 个结果(正在搜索… ${scanned} / ${pages} 页)`
 
7652
  }
7653
  if (!hasText) throw new Error("此 PDF 尚无可搜索文字层,需要完成 OCR 后才能搜索正文");
7654
  publishPdfSearchProgress(generation, groups, total, pattern, firstPage, pdf.numPages, pdf.numPages);
7655
+ return fullSearchSession.page.results;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7656
  }
7657
+
7658
+ async function searchReaderText(query, generation) {
7659
+ if ((capability.mode === "pdf" || pdfHybridUrl) && !ocrManifestUrl) {
7660
+ if (pdfHybridUrl) await pdfHybridDocumentPromise;
7661
+ await fullSearchPdfMatches(query, generation);
7662
+ }
7663
+ else if (["pdf", "pdf-pages"].includes(capability.mode))
7664
+ publishSearchResults(generation, await searchConcentratedPdf(query, generation));
7665
+ else if (capability.mode === "foliate")
7666
+ publishSearchResults(generation, await fullSearchFoliateMatches(query, generation));
7667
+ else if (capability.mode === "epub-chapters") await searchChapterBook(query, generation);
7668
+ else if (["text", "markdown", "docx"].includes(capability.mode))
7669
+ publishSearchResults(
7670
+ generation,
7671
+ await fullSearchDomMatches(content, "阅读位置", query, generation, true)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7672
  );
7673
+ else if (capability.mode === "html")
7674
+ publishSearchResults(
7675
+ generation,
7676
+ htmlFrame?.contentDocument?.body
7677
+ ? await fullSearchDomMatches(
7678
+ htmlFrame.contentDocument.body,
7679
+ "HTML 阅读位置",
7680
+ query,
7681
+ generation,
7682
+ true
7683
+ )
7684
+ : []
7685
+ );
7686
+ else throw new Error("此格式没有可搜索文本");
7687
  }
7688
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7689
  function syncCapabilityControls() {
7690
  content.dataset.mode = capability.mode || "unsupported";
7691
  pageInvertToggle.hidden = !["pdf", "pdf-pages"].includes(capability.mode);
tests/test_async_proxy.py CHANGED
@@ -403,21 +403,103 @@ class AsyncProxyTests(unittest.IsolatedAsyncioTestCase):
403
  whole_read.assert_not_awaited()
404
  self.assertEqual(response.status_code, status)
405
  self.assertEqual(response.headers["content-type"], mime)
 
406
  self.assertEqual(session.urls[0][1]["headers"], {"Accept-Encoding": "identity", **forwarded})
407
  if method != "HEAD":
408
  self.assertEqual([chunk async for chunk in response.body_iterator], [b"ab"])
409
  if forwarded:
410
  cached = await app.api_reader_bucket_resource(path, SimpleNamespace(method=method, headers=forwarded))
411
  self.assertEqual(cached.body, b"ab")
 
412
  self.assertEqual(len(session.urls), 1)
413
  self.assertTrue(upstream.released)
414
  self.assertEqual(semaphore._value, 1)
415
 
416
- async def test_bucket_media_preserves_resource_limit_and_cancellation_cleanup(self):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
417
  path = f"media/video/native/{'a' * 64}/video.mp4"
418
  for status, headers in (
419
- (200, {"Content-Length": str(app.READER_RESOURCE_MAX_BYTES + 1)}),
420
- (206, {"Content-Length": "2", "Content-Range": f"bytes 0-1/{app.READER_RESOURCE_MAX_BYTES + 1}"}),
 
421
  ):
422
  upstream = FakeResponse(status, headers)
423
  app.app.state.http_session = FakeSession([upstream])
@@ -425,7 +507,7 @@ class AsyncProxyTests(unittest.IsolatedAsyncioTestCase):
425
  app.app.state.reader_range_cache = OrderedDict()
426
  app.app.state.reader_range_cache_bytes = 0
427
  response = await app.api_reader_bucket_resource(path, SimpleNamespace(method="GET", headers={"Range": "bytes=0-1"}))
428
- self.assertEqual(response.status_code, 413)
429
  self.assertTrue(upstream.released)
430
  self.assertEqual(semaphore._value, 1)
431
  with patch.object(app, "open_download_response", AsyncMock(side_effect=asyncio.CancelledError)):
 
403
  whole_read.assert_not_awaited()
404
  self.assertEqual(response.status_code, status)
405
  self.assertEqual(response.headers["content-type"], mime)
406
+ self.assertNotIn("content-encoding", response.headers)
407
  self.assertEqual(session.urls[0][1]["headers"], {"Accept-Encoding": "identity", **forwarded})
408
  if method != "HEAD":
409
  self.assertEqual([chunk async for chunk in response.body_iterator], [b"ab"])
410
  if forwarded:
411
  cached = await app.api_reader_bucket_resource(path, SimpleNamespace(method=method, headers=forwarded))
412
  self.assertEqual(cached.body, b"ab")
413
+ self.assertNotIn("content-encoding", cached.headers)
414
  self.assertEqual(len(session.urls), 1)
415
  self.assertTrue(upstream.released)
416
  self.assertEqual(semaphore._value, 1)
417
 
418
+ async def test_media_remains_unencoded_through_real_gzip_middleware_and_range_cache(self):
419
+ path = f"media/audio/native/{'a' * 64}/audio.mp3"
420
+ payload = b"media" * 400
421
+ for status in (200, 206):
422
+ forwarded = {"Range": f"bytes=0-{len(payload) - 1}"} if status == 206 else {}
423
+ headers = {"Content-Length": str(len(payload)), "Content-Encoding": "identity"}
424
+ if status == 206:
425
+ headers["Content-Range"] = f"bytes 0-{len(payload) - 1}/{len(payload) * 2}"
426
+ upstream = FakeResponse(status, headers, chunks=(payload,))
427
+ app.app.state.http_session = FakeSession([upstream])
428
+ app.app.state.reader_range_cache = OrderedDict()
429
+ app.app.state.reader_range_cache_bytes = 0
430
+ for cached in (False, True) if status == 206 else (False,):
431
+ with self.subTest(status=status, cached=cached):
432
+ response = await app.api_reader_bucket_resource(path, SimpleNamespace(method="GET", headers=forwarded))
433
+ messages = []
434
+ async def serve(scope, receive, send):
435
+ await response(scope, receive, send)
436
+ async def receive():
437
+ return {"type": "http.request", "body": b"", "more_body": False}
438
+ async def send(message):
439
+ messages.append(message)
440
+ await app.GZipMiddleware(serve, minimum_size=1)(
441
+ {"type": "http", "method": "GET", "asgi": {"spec_version": "2.4"},
442
+ "headers": [(b"accept-encoding", b"gzip")]}, receive, send)
443
+ start = messages[0]
444
+ self.assertEqual(start["status"], status)
445
+ self.assertNotIn(b"content-encoding", dict(start["headers"]))
446
+ self.assertEqual(dict(start["headers"])[b"content-length"], str(len(payload)).encode())
447
+ self.assertEqual(b"".join(message.get("body", b"") for message in messages[1:]), payload)
448
+ self.assertTrue(upstream.released)
449
+ self.assertEqual(app.app.state.upstream_semaphore._value, 1)
450
+
451
+ async def test_large_media_streams_metadata_and_ranges_without_whole_file_limit(self):
452
+ total = app.READER_DOCUMENT_MAX_BYTES + 100
453
+ for kind, suffix in (("audio", "mp3"), ("video", "mp4")):
454
+ path = f"media/{kind}/native/{'a' * 64}/{kind}.{suffix}"
455
+ for method, forwarded, status, headers in (
456
+ ("HEAD", {}, 200, {"Content-Length": str(total)}),
457
+ ("GET", {"Range": "bytes=0-1"}, 206,
458
+ {"Content-Length": "2", "Content-Range": f"bytes 0-1/{total}"}),
459
+ ("GET", {"Range": "bytes=-2"}, 206,
460
+ {"Content-Length": "2", "Content-Range": f"bytes {total - 2}-{total - 1}/{total}"}),
461
+ ("GET", {"Range": f"bytes={total}-"}, 416,
462
+ {"Content-Length": "0", "Content-Range": f"bytes */{total}"}),
463
+ ):
464
+ for source in ("bucket", "original"):
465
+ with self.subTest(kind=kind, method=method, range=forwarded, source=source):
466
+ upstream = FakeResponse(status, headers, chunks=() if status == 416 else (b"ab",))
467
+ session = app.app.state.http_session = FakeSession([upstream])
468
+ semaphore = app.app.state.upstream_semaphore = asyncio.Semaphore(1)
469
+ app.app.state.reader_range_cache = OrderedDict()
470
+ app.app.state.reader_range_cache_bytes = 0
471
+ request = SimpleNamespace(method=method, headers=forwarded)
472
+ if source == "bucket":
473
+ response = await app.api_reader_bucket_resource(path, request)
474
+ else:
475
+ url = f"https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/large.{suffix}"
476
+ response = await app.api_reader_content(request, url=url, id=None)
477
+ self.assertEqual(response.status_code, status)
478
+ self.assertIsNone(session.urls[0][1]["timeout"].total)
479
+ self.assertEqual(session.urls[0][1]["timeout"].sock_read, 60)
480
+ if method != "HEAD":
481
+ self.assertEqual(b"".join([chunk async for chunk in response.body_iterator]),
482
+ b"" if status == 416 else b"ab")
483
+ self.assertTrue(upstream.released)
484
+ self.assertEqual(semaphore._value, 1)
485
+
486
+ async def test_media_unknown_length_stream_does_not_use_document_body_limit(self):
487
+ path = f"media/audio/native/{'a' * 64}/audio.mp3"
488
+ upstream = FakeResponse(200, chunks=(b"abc", b"def"))
489
+ app.app.state.http_session = FakeSession([upstream])
490
+ with patch.object(app, "READER_DOCUMENT_MAX_BYTES", 2), patch.object(app, "READER_RESOURCE_MAX_BYTES", 2):
491
+ response = await app.api_reader_bucket_resource(path, SimpleNamespace(method="GET", headers={}))
492
+ self.assertEqual(response.status_code, 200)
493
+ self.assertEqual([chunk async for chunk in response.body_iterator], [b"abc", b"def"])
494
+ self.assertTrue(upstream.released)
495
+ self.assertEqual(app.app.state.upstream_semaphore._value, 1)
496
+
497
+ async def test_bucket_media_preserves_range_validation_and_cancellation_cleanup(self):
498
  path = f"media/video/native/{'a' * 64}/video.mp4"
499
  for status, headers in (
500
+ (206, {"Content-Length": "2", "Content-Range": "invalid"}),
501
+ (206, {"Content-Length": "2", "Content-Range": "bytes 0-2/100"}),
502
+ (206, {"Content-Length": "2", "Content-Range": "bytes 99-100/100"}),
503
  ):
504
  upstream = FakeResponse(status, headers)
505
  app.app.state.http_session = FakeSession([upstream])
 
507
  app.app.state.reader_range_cache = OrderedDict()
508
  app.app.state.reader_range_cache_bytes = 0
509
  response = await app.api_reader_bucket_resource(path, SimpleNamespace(method="GET", headers={"Range": "bytes=0-1"}))
510
+ self.assertEqual(response.status_code, 502)
511
  self.assertTrue(upstream.released)
512
  self.assertEqual(semaphore._value, 1)
513
  with patch.object(app, "open_download_response", AsyncMock(side_effect=asyncio.CancelledError)):
tests/test_reader_architecture.js CHANGED
@@ -5,6 +5,12 @@ const { assertCode, codePattern } = require('./reader_source_contract')
5
 
6
  const root = path.resolve(__dirname, '..')
7
  const reader = fs.readFileSync(path.join(root, 'static/reader.js'), 'utf8')
 
 
 
 
 
 
8
  const css = fs.readFileSync(path.join(root, 'static/reader.css'), 'utf8')
9
  const view = fs.readFileSync(path.join(root, 'static/foliate-reader/view.js'), 'utf8')
10
  for (const marker of ["import('./epub.js')", "import('./mobi.js')", "import('./fb2.js')", "import('./pdf.js')", "isFBZ(file)", "isFB2(file)"]) assert.ok(view.includes(marker), marker)
@@ -121,7 +127,7 @@ const renderFoliateSource = reader.match(/async\s+function\s+renderFoliate\s*\([
121
  assert.strictEqual(renderFoliateSource.includes('loaded = new Set'), false)
122
  assert.strictEqual(renderFoliateSource.includes('pending = new Map'), false)
123
  assert.match(reader, /stream\.insertBefore\(article, next \|\| null\)/)
124
- assert.match(reader, /foliateSectionLoader = \(index\)/)
125
  assert.doesNotMatch(reader, /^let navigationGeneration\b/m)
126
  assert.doesNotMatch(reader, /syncReaderState/)
127
  assert.doesNotMatch(reader, /^(?:let|var|const)\s+title\b/m)
@@ -129,7 +135,8 @@ for (const mirror of ['zoom', 'currentPage', 'pageCount', 'restoredEntry', 'rest
129
  assert.doesNotMatch(reader, /readerRuntime\.updateFormat\(/)
130
  for (const domain of ['document', 'navigation', 'search', 'panel']) assert.match(reader, new RegExp(`readerRuntime\\.state\\.${domain}`))
131
  assert.match(reader, /readerRuntime\.currentGeneration\("pdf"\)/)
132
- assert.match(reader, /const generation = nextReaderGeneration\("search"\)/)
 
133
  assert.match(reader, /isReaderGenerationCurrent\("navigation", generation\)/)
134
  assert.match(reader, /registerReaderFormatAdapters\(\)/)
135
  assert.match(reader, /function disposeFormatResources\(mode\)/)
 
5
 
6
  const root = path.resolve(__dirname, '..')
7
  const reader = fs.readFileSync(path.join(root, 'static/reader.js'), 'utf8')
8
+ const searchController = fs.readFileSync(path.join(root, 'static/reader-search-controller.mjs'), 'utf8')
9
+ assertCode(reader, 'import { createReaderSearchController } from "./reader-search-controller.mjs"')
10
+ assertCode(reader, 'trackReaderResource(fullSearchController.dispose)')
11
+ assertCode(reader, 'backend: { mode: () => capability.mode, search: searchReaderText')
12
+ assert.doesNotMatch(reader, /dataset\.panelView\s*=\s*"full-search"/)
13
+ assert.doesNotMatch(searchController, /new Worker|\bfetch\(|import\(/)
14
  const css = fs.readFileSync(path.join(root, 'static/reader.css'), 'utf8')
15
  const view = fs.readFileSync(path.join(root, 'static/foliate-reader/view.js'), 'utf8')
16
  for (const marker of ["import('./epub.js')", "import('./mobi.js')", "import('./fb2.js')", "import('./pdf.js')", "isFBZ(file)", "isFB2(file)"]) assert.ok(view.includes(marker), marker)
 
127
  assert.strictEqual(renderFoliateSource.includes('loaded = new Set'), false)
128
  assert.strictEqual(renderFoliateSource.includes('pending = new Map'), false)
129
  assert.match(reader, /stream\.insertBefore\(article, next \|\| null\)/)
130
+ assert.match(reader, /foliateSectionLoader\s*=\s*(?:\(index\)|index)\s*=>/)
131
  assert.doesNotMatch(reader, /^let navigationGeneration\b/m)
132
  assert.doesNotMatch(reader, /syncReaderState/)
133
  assert.doesNotMatch(reader, /^(?:let|var|const)\s+title\b/m)
 
135
  assert.doesNotMatch(reader, /readerRuntime\.updateFormat\(/)
136
  for (const domain of ['document', 'navigation', 'search', 'panel']) assert.match(reader, new RegExp(`readerRuntime\\.state\\.${domain}`))
137
  assert.match(reader, /readerRuntime\.currentGeneration\("pdf"\)/)
138
+ assertCode(reader, 'nextGeneration: () => nextReaderGeneration("search")')
139
+ assertCode(searchController, 'const generation = nextGeneration()')
140
  assert.match(reader, /isReaderGenerationCurrent\("navigation", generation\)/)
141
  assert.match(reader, /registerReaderFormatAdapters\(\)/)
142
  assert.match(reader, /function disposeFormatResources\(mode\)/)
tests/test_reader_optimizations.py CHANGED
@@ -58,15 +58,15 @@ class ReaderOptimizationTests(unittest.TestCase):
58
  window.__pdfProbeTask = fullSearchPdfMatches('needle', generation);
59
  window.__pdfProbeTask.catch(() => {});
60
  };
61
- window.__pdfProbePage = offset => pdfSearchPageLoader(offset);
62
- window.__pdfProbeTotal = () => chapterSearchPage.total;
63
- window.__cancelPdfProbe = () => {nextReaderGeneration('search'); chapterSearchPage = {total:123};};
64
  window.__retryCancelledPageProbe = () => loadPdfTextContent(2, {
65
  getTextContent:async()=>({items:[{str:'fresh demand'}],styles:{}})});
66
  window.__query = async query => {
67
  fullSearchInput.value = query;
68
  await runFullSearch();
69
- return chapterSearchPage?.total || 0;
70
  };
71
  window.__goChapter = index => navigationState.tocEntries[index-1].activate(beginReaderNavigation());
72
  window.__walkProbe = () => {
 
58
  window.__pdfProbeTask = fullSearchPdfMatches('needle', generation);
59
  window.__pdfProbeTask.catch(() => {});
60
  };
61
+ window.__pdfProbePage = offset => fullSearchSession.pageLoader(offset);
62
+ window.__pdfProbeTotal = () => fullSearchSession.page.total;
63
+ window.__cancelPdfProbe = () => {nextReaderGeneration('search'); fullSearchSession.page = {total:123};};
64
  window.__retryCancelledPageProbe = () => loadPdfTextContent(2, {
65
  getTextContent:async()=>({items:[{str:'fresh demand'}],styles:{}})});
66
  window.__query = async query => {
67
  fullSearchInput.value = query;
68
  await runFullSearch();
69
+ return fullSearchSession.page?.total || 0;
70
  };
71
  window.__goChapter = index => navigationState.tocEntries[index-1].activate(beginReaderNavigation());
72
  window.__walkProbe = () => {
tests/test_reader_refactor.py CHANGED
@@ -1515,7 +1515,14 @@ class ReaderRefactorTest(unittest.TestCase):
1515
  self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '1 个结果'")
1516
  self.assertEqual(requests, [book_path] * 3)
1517
 
 
 
 
 
1518
  def test_media_proxy_failure_retries_original_once(self):
 
 
 
1519
  buffer = io.BytesIO()
1520
  with wave.open(buffer, 'wb') as audio:
1521
  audio.setparams((1, 2, 8000, 8000, 'NONE', 'not compressed'))
@@ -1529,11 +1536,15 @@ class ReaderRefactorTest(unittest.TestCase):
1529
  route.fulfill(status=206, content_type='audio/wav', body=data[start:],
1530
  headers={'Accept-Ranges': 'bytes', 'Content-Range': f'bytes {start}-{len(data)-1}/{len(data)}'})
1531
  self.page.route('https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/refactor.wav', original)
1532
- self.open(self.reader_url('wav'))
 
 
 
 
1533
  self.page.wait_for_function("() => document.querySelector('audio').readyState >= 1")
1534
  self.assertEqual(len(requests), 1)
1535
  self.assertEqual(self.page.locator('audio').evaluate('node => node.duration'), 1)
1536
- self.page.evaluate("async () => { const media = document.querySelector('audio'); media.currentTime = 0.5; await VoiceOfMLReaderStore.put({url: media.src, mediaTime: 0.5}); }")
1537
  self.page.reload()
1538
  self.page.wait_for_function("() => document.documentElement.dataset.readerPhase === 'ready' && document.querySelector('audio').currentTime === 0.5")
1539
  self.assertEqual(len(requests), 2)
@@ -2205,6 +2216,27 @@ class ReaderRefactorTest(unittest.TestCase):
2205
  self.assertTrue(self.page.locator("#full-search-next").is_disabled())
2206
  self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '1 个结果'")
2207
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2208
  def test_pending_text_result_cannot_highlight_after_new_search(self):
2209
  def hold_activation(route):
2210
  response = route.fetch()
 
1515
  self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '1 个结果'")
1516
  self.assertEqual(requests, [book_path] * 3)
1517
 
1518
+ def test_bucket_media_failure_retries_download_original_once(self):
1519
+ self.page.route('**/api/reader-bucket-resource**', lambda route: route.fulfill(status=503, body='unavailable'))
1520
+ self.assert_media_failure_retries_original_once(bucket=True)
1521
+
1522
  def test_media_proxy_failure_retries_original_once(self):
1523
+ self.assert_media_failure_retries_original_once()
1524
+
1525
+ def assert_media_failure_retries_original_once(self, bucket=False):
1526
  buffer = io.BytesIO()
1527
  with wave.open(buffer, 'wb') as audio:
1528
  audio.setparams((1, 2, 8000, 8000, 'NONE', 'not compressed'))
 
1536
  route.fulfill(status=206, content_type='audio/wav', body=data[start:],
1537
  headers={'Accept-Ranges': 'bytes', 'Content-Range': f'bytes {start}-{len(data)-1}/{len(data)}'})
1538
  self.page.route('https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/refactor.wav', original)
1539
+ options = {}
1540
+ if bucket:
1541
+ options = dict(url=self.origin + '/api/reader-bucket-resource?path=media/audio/native/' + 'a' * 64 + '/audio.wav',
1542
+ download='https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/refactor.wav')
1543
+ self.open(self.reader_url('wav', **options))
1544
  self.page.wait_for_function("() => document.querySelector('audio').readyState >= 1")
1545
  self.assertEqual(len(requests), 1)
1546
  self.assertEqual(self.page.locator('audio').evaluate('node => node.duration'), 1)
1547
+ self.page.evaluate("async () => { const media = document.querySelector('audio'); media.currentTime = 0.5; await VoiceOfMLReaderStore.put({url: new URL(location.href).searchParams.get('url'), mediaTime: 0.5}); }")
1548
  self.page.reload()
1549
  self.page.wait_for_function("() => document.documentElement.dataset.readerPhase === 'ready' && document.querySelector('audio').currentTime === 0.5")
1550
  self.assertEqual(len(requests), 2)
 
2216
  self.assertTrue(self.page.locator("#full-search-next").is_disabled())
2217
  self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '1 个结果'")
2218
 
2219
+ def test_disposal_cancels_debounced_full_search(self):
2220
+ self.page.add_init_script("""const nativeTimeout = window.setTimeout.bind(window);
2221
+ window.__searchDebounceCallbacks = 0;
2222
+ window.setTimeout = (callback, delay, ...args) => nativeTimeout(
2223
+ delay === 180 ? (...values) => {
2224
+ window.__searchDebounceCallbacks++;
2225
+ callback(...values);
2226
+ } : callback, delay, ...args);""")
2227
+ self.serve("needle " * 100)
2228
+ self.open(self.reader_url())
2229
+ self.page.evaluate("""() => {
2230
+ const input = document.querySelector('#full-search-input');
2231
+ input.value = 'needle';
2232
+ input.dispatchEvent(new Event('input', {bubbles: true}));
2233
+ window.dispatchEvent(new Event('voice-reader-dispose'));
2234
+ }""")
2235
+ self.page.wait_for_timeout(300)
2236
+ self.assertEqual(self.page.locator('html').get_attribute('data-reader-phase'), 'disposed')
2237
+ self.assertEqual(self.page.evaluate('window.__searchDebounceCallbacks'), 0)
2238
+ self.assertEqual(self.page.locator('.full-search-result').count(), 0)
2239
+
2240
  def test_pending_text_result_cannot_highlight_after_new_search(self):
2241
  def hold_activation(route):
2242
  response = route.fetch()
tests/test_reader_style.py CHANGED
@@ -1,6 +1,7 @@
1
  """Focused browser checks for reader CSS, independent of format-engine suites."""
2
 
3
  import io
 
4
  import unittest
5
  import urllib.parse
6
  import zipfile
@@ -28,7 +29,7 @@ class ReaderStyleTests(unittest.TestCase):
28
  cls.playwright = sync_playwright().start()
29
  cls.addClassCleanup(cls.playwright.stop)
30
  try:
31
- cls.browser = cls.playwright.chromium.launch(headless=True)
32
  except PlaywrightError as error:
33
  raise unittest.SkipTest(str(error))
34
  cls.addClassCleanup(cls.browser.close)
@@ -59,6 +60,49 @@ class ReaderStyleTests(unittest.TestCase):
59
  !document.getAnimations().some(animation => animation.playState === 'running')
60
  """)
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  def assert_contrast(self, selector):
63
  result = self.page.locator(selector).first.evaluate("""element => {
64
  const rgb = value => value.match(/[\\d.]+/g).map(Number);
 
1
  """Focused browser checks for reader CSS, independent of format-engine suites."""
2
 
3
  import io
4
+ import os
5
  import unittest
6
  import urllib.parse
7
  import zipfile
 
29
  cls.playwright = sync_playwright().start()
30
  cls.addClassCleanup(cls.playwright.stop)
31
  try:
32
+ cls.browser = getattr(cls.playwright, os.environ.get("READER_STYLE_BROWSER", "chromium")).launch(headless=True)
33
  except PlaywrightError as error:
34
  raise unittest.SkipTest(str(error))
35
  cls.addClassCleanup(cls.browser.close)
 
60
  !document.getAnimations().some(animation => animation.playState === 'running')
61
  """)
62
 
63
+ def test_back_and_download_icons_have_stable_browser_independent_geometry(self):
64
+ self.page.evaluate("""() => {
65
+ document.querySelector('.page-controls').hidden = false;
66
+ document.querySelector('.zoom-controls').hidden = false;
67
+ }""")
68
+ for theme in ("dark", "light"):
69
+ self.page.evaluate("theme => document.documentElement.dataset.theme = theme", theme)
70
+ for width in (1100, 390, 320):
71
+ self.page.set_viewport_size({"width": width, "height": 900})
72
+ for selector in ("#back", "#download"):
73
+ with self.subTest(theme=theme, width=width, selector=selector):
74
+ button = self.page.locator(selector)
75
+ geometry = button.evaluate("""button => {
76
+ const icon = button.querySelector('svg');
77
+ if (!icon) return null;
78
+ const b = button.getBoundingClientRect(), i = icon.getBoundingClientRect();
79
+ const toolbar = button.closest('header').getBoundingClientRect();
80
+ const style = getComputedStyle(button);
81
+ return {width: b.width, height: b.height, iconWidth: i.width, iconHeight: i.height,
82
+ dx: i.x + i.width / 2 - b.x - b.width / 2,
83
+ dy: i.y + i.height / 2 - b.y - b.height / 2,
84
+ fits: b.left >= 0 && b.right <= innerWidth && b.top >= toolbar.top && b.bottom <= toolbar.bottom,
85
+ padding: style.padding, appearance: style.appearance,
86
+ stroke: getComputedStyle(icon).stroke, color: style.color,
87
+ hiddenIcon: icon.getAttribute('aria-hidden'), focusable: icon.getAttribute('focusable')};
88
+ }""")
89
+ self.assertIsNotNone(geometry)
90
+ self.assertEqual((geometry["width"], geometry["height"]), (32, 32) if width > 600 else (26, 30))
91
+ self.assertEqual((geometry["iconWidth"], geometry["iconHeight"]), (18, 18))
92
+ self.assertAlmostEqual(geometry["dx"], 0, delta=0.5)
93
+ self.assertAlmostEqual(geometry["dy"], 0, delta=0.5)
94
+ self.assertTrue(geometry["fits"], geometry)
95
+ self.assertEqual((geometry["padding"], geometry["appearance"]), ("0px", "none"))
96
+ self.assertEqual(geometry["stroke"], geometry["color"])
97
+ self.assertEqual((geometry["hiddenIcon"], geometry["focusable"]), ("true", "false"))
98
+ self.assertTrue(button.get_attribute("title"))
99
+ self.assertTrue(button.get_attribute("aria-label"))
100
+ before = button.bounding_box()
101
+ button.hover()
102
+ button.focus()
103
+ self.assertEqual(button.bounding_box(), before)
104
+ self.assertEqual(button.evaluate("e => getComputedStyle(e).outlineStyle"), "solid")
105
+
106
  def assert_contrast(self, selector):
107
  result = self.page.locator(selector).first.evaluate("""element => {
108
  const rgb = value => value.match(/[\\d.]+/g).map(Number);
tests/test_static_contract.py CHANGED
@@ -113,12 +113,14 @@ class StaticFrontendContractTests(unittest.TestCase):
113
  reader_css = (ROOT / "static/reader.css").read_text(encoding="utf-8")
114
  reader_html = (ROOT / "static/reader.html").read_text(encoding="utf-8")
115
  reader_store = (ROOT / "static/reader-store.js").read_text(encoding="utf-8")
116
- self.assertEqual(len(re.findall(r'dataset\.panelView\s*=\s*"full-search"', reader)), 1)
 
 
117
  self.assertNotIn("window.find", reader)
118
  for value in ('id="full-search-input"', 'id="full-search-status"', 'id="full-search-results"', 'id="full-search-prev"', 'id="full-search-next"'):
119
- self.assertIn(value, reader)
120
- self.assertIn("fullSearchSnippetDom", reader)
121
- assert_source_contains(self, 'textContent = "全文搜索"', reader)
122
  self.assertIn('circle cx="12" cy="12"', reader)
123
  self.assertIn("async function activateChapter", reader)
124
  assert_source_contains(self, "progressUndo.hidden = !previousProgressState", reader)
 
113
  reader_css = (ROOT / "static/reader.css").read_text(encoding="utf-8")
114
  reader_html = (ROOT / "static/reader.html").read_text(encoding="utf-8")
115
  reader_store = (ROOT / "static/reader-store.js").read_text(encoding="utf-8")
116
+ search_controller = (ROOT / "static/reader-search-controller.mjs").read_text(encoding="utf-8")
117
+ self.assertIn('import { createReaderSearchController } from "./reader-search-controller.mjs"', reader)
118
+ self.assertEqual(len(re.findall(r'dataset\.panelView\s*=\s*"full-search"', search_controller)), 1)
119
  self.assertNotIn("window.find", reader)
120
  for value in ('id="full-search-input"', 'id="full-search-status"', 'id="full-search-results"', 'id="full-search-prev"', 'id="full-search-next"'):
121
+ self.assertIn(value, search_controller)
122
+ self.assertIn("snippetDom", search_controller)
123
+ assert_source_contains(self, 'textContent = "全文搜索"', search_controller)
124
  self.assertIn('circle cx="12" cy="12"', reader)
125
  self.assertIn("async function activateChapter", reader)
126
  assert_source_contains(self, "progressUndo.hidden = !previousProgressState", reader)