vomebook commited on
Commit
19a019e
·
verified ·
1 Parent(s): e5d3b3e

Accelerate Reader content loading

Browse files
app.py CHANGED
@@ -14,7 +14,7 @@ from urllib.parse import unquote, quote, urljoin, urlparse
14
  import aiohttp
15
  import jieba
16
  from fastapi import FastAPI, Query, Request
17
- from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse, StreamingResponse
18
  from fastapi.staticfiles import StaticFiles
19
  from fastapi.middleware.cors import CORSMiddleware
20
  from fastapi.middleware.gzip import GZipMiddleware
@@ -390,15 +390,58 @@ def validate_voiceofml_source_url(url: str) -> str:
390
  raise ValueError("只允许 VoiceOfML 数据集文件")
391
  return url
392
 
393
- async def open_download_response(session, url: str, timeout: aiohttp.ClientTimeout):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
  current_url = validate_download_url(url)
 
 
395
  for _ in range(MAX_REDIRECTS + 1):
396
- response = await session.get(
 
 
 
 
 
 
 
 
 
 
397
  current_url,
398
  allow_redirects=False,
399
  timeout=timeout,
400
- headers={"Accept-Encoding": "identity"},
401
- )
402
  if response.status not in (301, 302, 303, 307, 308):
403
  return response
404
  location = response.headers.get("Location")
@@ -1224,6 +1267,7 @@ app.add_middleware(
1224
  allow_origins=["*"],
1225
  allow_methods=["*"],
1226
  allow_headers=["*"],
 
1227
  )
1228
 
1229
  @app.middleware("http")
@@ -1399,6 +1443,65 @@ def api_bootstrap():
1399
  def api_ping():
1400
  return PlainTextResponse(status_code=204, headers={"Cache-Control": "no-store"})
1401
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1402
  @app.get("/api/download")
1403
 
1404
  async def api_download(file: str = Query(...), link: str = Query(...)):
@@ -1519,7 +1622,7 @@ async def serve_txt_proxy(txt_path: str):
1519
 
1520
  return StreamingResponse(stream_txt(), media_type="text/plain; charset=utf-8")
1521
 
1522
- HASHED_ASSET_RE = re.compile(r"\.[0-9a-f]{12}\.(?:js|css)$")
1523
 
1524
 
1525
  class HashedStaticFiles(StaticFiles):
 
14
  import aiohttp
15
  import jieba
16
  from fastapi import FastAPI, Query, Request
17
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse, Response, StreamingResponse
18
  from fastapi.staticfiles import StaticFiles
19
  from fastapi.middleware.cors import CORSMiddleware
20
  from fastapi.middleware.gzip import GZipMiddleware
 
390
  raise ValueError("只允许 VoiceOfML 数据集文件")
391
  return url
392
 
393
+
394
+ READER_ASSET_SOURCE_RE = re.compile(
395
+ r"^/datasets/vomebook/Reader-Assets/resolve/main/objects/[0-9a-f]{2}/[0-9a-f]{64}/(?:[a-z0-9-]+/)?(?:document\.pdf|book\.epub)$"
396
+ )
397
+ VOICEOFML_READER_SOURCE_RE = re.compile(r"^/datasets/VoiceOfML/[A-Za-z0-9._-]+/(?:resolve|raw)/main/.+$")
398
+
399
+
400
+ def validate_reader_source_url(url: str) -> str:
401
+ url = normalize_download_url(url)
402
+ validate_download_url(url)
403
+ parsed = urlparse(url)
404
+ if parsed.hostname != "huggingface.co" or parsed.fragment or parsed.query:
405
+ raise ValueError("不允许的阅读来源")
406
+ decoded_path = unquote(parsed.path)
407
+ if "\\" in decoded_path or "\x00" in decoded_path or any(part in (".", "..") for part in decoded_path.split("/")):
408
+ raise ValueError("不允许的阅读来源路径")
409
+ if VOICEOFML_READER_SOURCE_RE.fullmatch(decoded_path) or READER_ASSET_SOURCE_RE.fullmatch(decoded_path):
410
+ return url
411
+ raise ValueError("不允许的阅读来源")
412
+
413
+
414
+ async def open_download_response(
415
+ session,
416
+ url: str,
417
+ timeout: aiohttp.ClientTimeout,
418
+ *,
419
+ method: str = "GET",
420
+ request_headers: Optional[dict[str, str]] = None,
421
+ ):
422
+ method = method.upper()
423
+ if method not in ("GET", "HEAD"):
424
+ raise ValueError("不支持的上游请求方法")
425
  current_url = validate_download_url(url)
426
+ headers = {"Accept-Encoding": "identity"}
427
+ headers.update(request_headers or {})
428
  for _ in range(MAX_REDIRECTS + 1):
429
+ request_method = getattr(session, "request", None)
430
+ if request_method is not None:
431
+ response = await request_method(
432
+ method,
433
+ current_url,
434
+ allow_redirects=False,
435
+ timeout=timeout,
436
+ headers=headers,
437
+ )
438
+ else:
439
+ response = await session.get(
440
  current_url,
441
  allow_redirects=False,
442
  timeout=timeout,
443
+ headers=headers,
444
+ )
445
  if response.status not in (301, 302, 303, 307, 308):
446
  return response
447
  location = response.headers.get("Location")
 
1267
  allow_origins=["*"],
1268
  allow_methods=["*"],
1269
  allow_headers=["*"],
1270
+ expose_headers=["Accept-Ranges", "Content-Range", "Content-Length", "ETag", "Last-Modified"],
1271
  )
1272
 
1273
  @app.middleware("http")
 
1443
  def api_ping():
1444
  return PlainTextResponse(status_code=204, headers={"Cache-Control": "no-store"})
1445
 
1446
+
1447
+ READER_UPSTREAM_RESPONSE_HEADERS = (
1448
+ "Content-Type", "Content-Length", "Content-Range", "Accept-Ranges",
1449
+ "ETag", "Last-Modified", "Cache-Control",
1450
+ )
1451
+
1452
+
1453
+ @app.api_route("/api/reader-content", methods=["GET", "HEAD"])
1454
+ async def api_reader_content(request: Request, url: str = Query(...)):
1455
+ try:
1456
+ target_url = validate_reader_source_url(url)
1457
+ except ValueError as exc:
1458
+ return JSONResponse({"error": str(exc)}, status_code=403)
1459
+ forwarded_headers = {
1460
+ name: request.headers[name]
1461
+ for name in ("Range", "If-Range")
1462
+ if name in request.headers
1463
+ }
1464
+ semaphore = app.state.upstream_semaphore
1465
+ await semaphore.acquire()
1466
+ try:
1467
+ upstream = await open_download_response(
1468
+ app.state.http_session,
1469
+ target_url,
1470
+ aiohttp.ClientTimeout(total=180, connect=15, sock_read=60),
1471
+ method=request.method,
1472
+ request_headers=forwarded_headers,
1473
+ )
1474
+ except Exception as exc:
1475
+ semaphore.release()
1476
+ print(f"阅读代理异常: {exc}")
1477
+ return JSONResponse({"error": str(exc) or "上游阅读文件加载失败"}, status_code=502)
1478
+ headers = {name: upstream.headers[name] for name in READER_UPSTREAM_RESPONSE_HEADERS if name in upstream.headers}
1479
+ headers.setdefault("Cache-Control", "public, max-age=300")
1480
+ headers["Content-Encoding"] = "identity"
1481
+ status = upstream.status
1482
+ if request.method == "HEAD":
1483
+ upstream.release()
1484
+ semaphore.release()
1485
+ return Response(status_code=status, headers=headers)
1486
+ if status not in (200, 206, 304, 416):
1487
+ upstream.release()
1488
+ semaphore.release()
1489
+ return JSONResponse({"error": f"上游阅读文件加载失败: HTTP {status}"}, status_code=status)
1490
+
1491
+ async def stream_reader_content():
1492
+ try:
1493
+ async with upstream:
1494
+ async for chunk in upstream.content.iter_chunked(65536):
1495
+ yield chunk
1496
+ except Exception as exc:
1497
+ print(f"阅读代理异常: {exc}")
1498
+ raise
1499
+ finally:
1500
+ semaphore.release()
1501
+
1502
+ return StreamingResponse(stream_reader_content(), status_code=status, headers=headers)
1503
+
1504
+
1505
  @app.get("/api/download")
1506
 
1507
  async def api_download(file: str = Query(...), link: str = Query(...)):
 
1622
 
1623
  return StreamingResponse(stream_txt(), media_type="text/plain; charset=utf-8")
1624
 
1625
+ HASHED_ASSET_RE = re.compile(r"\.[0-9a-f]{12}\.(?:js|css|mjs)$")
1626
 
1627
 
1628
  class HashedStaticFiles(StaticFiles):
scripts/copy_reader_vendor.mjs CHANGED
@@ -50,6 +50,9 @@ for (const [url, target, expected] of [
50
  const actual = createHash("sha256").update(bytes).digest("hex");
51
  if (actual !== expected) throw new Error(`${url}: SHA-256 ${actual} does not match ${expected}`);
52
  writeFileSync(join(output, target), bytes);
 
 
 
53
  }
54
  }
55
 
 
50
  const actual = createHash("sha256").update(bytes).digest("hex");
51
  if (actual !== expected) throw new Error(`${url}: SHA-256 ${actual} does not match ${expected}`);
52
  writeFileSync(join(output, target), bytes);
53
+ const dot = target.lastIndexOf(".");
54
+ const versioned = `${target.slice(0, dot)}.${actual.slice(0, 12)}${target.slice(dot)}`;
55
+ writeFileSync(join(output, versioned), bytes);
56
  }
57
  }
58
 
static/app.js CHANGED
@@ -744,6 +744,31 @@ function navigateToReader(rawUrl, returnUrl = location.href) {
744
  return true;
745
  }
746
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
747
  function isReadableRecord(rec) {
748
  return VoiceOfMLReader.capability(rec && (rec.ReaderExtension || rec.Extension)).article;
749
  }
@@ -2946,6 +2971,7 @@ function setupResultDelegation() {
2946
 
2947
  async function init() {
2948
  cacheDOM();
 
2949
  STATE.isDark = localStorage.getItem("theme") !== "light";
2950
  applyTheme();
2951
  const savedMobile = localStorage.getItem("mobileMode");
 
744
  return true;
745
  }
746
 
747
+ const warmedReaderAssets = new Set();
748
+ function warmReaderIntent(rawUrl) {
749
+ if (!rawUrl) return;
750
+ let extension = "";
751
+ try { extension = (new URL(rawUrl, location.origin).searchParams.get("ext") || "").toLowerCase(); } catch (_) { return; }
752
+ const assets = extension === "pdf"
753
+ ? ["/static/vendor/pdf.min.e0be3863c23c.mjs", "/static/pdf-worker-wrapper.mjs", "/static/vendor/pdf.worker.min.0613f41490dd.mjs"]
754
+ : extension === "epub" ? ["/static/vendor/epub.min.06eae1574510.js"]
755
+ : ["md", "markdown"].includes(extension) ? ["/static/vendor/marked.min.eaccee2fb9fb.js", "/static/vendor/purify.min.c2f26ea4fc0d.js"] : [];
756
+ for (const href of assets) {
757
+ if (warmedReaderAssets.has(href)) continue;
758
+ warmedReaderAssets.add(href);
759
+ const link = document.createElement("link"); link.rel = "prefetch"; link.href = href; document.head.appendChild(link);
760
+ }
761
+ try { fetch("/api/ping", { cache: "no-store" }).catch(() => {}); } catch (_) {}
762
+ }
763
+
764
+ function setupReaderIntentWarming() {
765
+ const warm = (event) => {
766
+ const target = event.target.closest("[data-reader-url], [data-read-url]");
767
+ if (target) warmReaderIntent(target.dataset.readerUrl || target.dataset.readUrl);
768
+ };
769
+ for (const type of ["pointerover", "pointerdown", "focusin"]) document.addEventListener(type, warm, { passive: true });
770
+ }
771
+
772
  function isReadableRecord(rec) {
773
  return VoiceOfMLReader.capability(rec && (rec.ReaderExtension || rec.Extension)).article;
774
  }
 
2971
 
2972
  async function init() {
2973
  cacheDOM();
2974
+ setupReaderIntentWarming();
2975
  STATE.isDark = localStorage.getItem("theme") !== "light";
2976
  applyTheme();
2977
  const savedMobile = localStorage.getItem("mobileMode");
static/pdf-worker-wrapper.mjs CHANGED
@@ -23,7 +23,7 @@ const pendingMessages = [];
23
  const bufferMessage = event => pendingMessages.push(event);
24
  self.addEventListener("message", bufferMessage);
25
 
26
- import("./vendor/pdf.worker.min.mjs").then(() => {
27
  self.removeEventListener("message", bufferMessage);
28
  for (const event of pendingMessages) self.dispatchEvent(new MessageEvent("message", { data: event.data }));
29
  });
 
23
  const bufferMessage = event => pendingMessages.push(event);
24
  self.addEventListener("message", bufferMessage);
25
 
26
+ import("./vendor/pdf.worker.min.0613f41490dd.mjs").then(() => {
27
  self.removeEventListener("message", bufferMessage);
28
  for (const event of pendingMessages) self.dispatchEvent(new MessageEvent("message", { data: event.data }));
29
  });
static/reader.html CHANGED
@@ -6,9 +6,10 @@
6
  <meta name="theme-color" content="#181a1d">
7
  <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data: blob: https://huggingface.co https://hf-mirror.com https://*.huggingface.co https://*.hf.co https://*.xethub.hf.co; connect-src 'self' blob: https://huggingface.co https://hf-mirror.com https://*.huggingface.co https://*.hf.co https://*.xethub.hf.co; worker-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; base-uri 'none'; form-action 'none'; object-src 'none'">
8
  <title>VoiceOfML Reader</title>
 
9
  <link rel="stylesheet" href="/static/reader.css">
10
- <script src="/static/reader-contract.js"></script>
11
- <script src="/static/reader-store.js"></script>
12
  </head>
13
  <body>
14
  <header class="reader-toolbar">
 
6
  <meta name="theme-color" content="#181a1d">
7
  <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data: blob: https://huggingface.co https://hf-mirror.com https://*.huggingface.co https://*.hf.co https://*.xethub.hf.co; connect-src 'self' blob: https://huggingface.co https://hf-mirror.com https://*.huggingface.co https://*.hf.co https://*.xethub.hf.co; worker-src 'self' blob:; frame-src 'self' blob:; font-src 'self' data:; base-uri 'none'; form-action 'none'; object-src 'none'">
8
  <title>VoiceOfML Reader</title>
9
+ <link rel="preconnect" href="https://huggingface.co" crossorigin>
10
  <link rel="stylesheet" href="/static/reader.css">
11
+ <script defer src="/static/reader-contract.js"></script>
12
+ <script defer src="/static/reader-store.js"></script>
13
  </head>
14
  <body>
15
  <header class="reader-toolbar">
static/reader.js CHANGED
@@ -1,8 +1,8 @@
1
- const PDFJS_URL = "/static/vendor/pdf.min.mjs";
2
  const PDFJS_WORKER_URL = "/static/pdf-worker-wrapper.mjs";
3
- const EPUB_URL = "/static/vendor/epub.min.js";
4
- const MARKED_URL = "/static/vendor/marked.min.js";
5
- const PURIFY_URL = "/static/vendor/purify.min.js";
6
  if (!Map.prototype.getOrInsertComputed) {
7
  Map.prototype.getOrInsertComputed = function(key, callback) {
8
  if (this.has(key)) return this.get(key);
@@ -25,6 +25,7 @@ if (!Math.sumPrecise) {
25
  }
26
  const params = new URLSearchParams(location.search);
27
  const sourceUrl = params.get("url") || "";
 
28
  const downloadUrl = params.get("download") || sourceUrl;
29
  const extension = (params.get("ext") || "").toLowerCase();
30
  const capability = VoiceOfMLReader.capability(extension);
@@ -52,6 +53,8 @@ let saveTimer = 0;
52
  let pdfDocument = null;
53
  let pdfRenderGeneration = 0;
54
  let pdfActiveRenders = 0;
 
 
55
  const pdfRenderWaiters = [];
56
  let epubRendition = null;
57
  let epubLocation = "";
@@ -98,11 +101,12 @@ document.querySelector("#page-next").addEventListener("click", () => epubRenditi
98
  async function goToPage(value) {
99
  if (!pageCount) return;
100
  const page = VoiceOfMLReader.clampNumber(value, 1, pageCount, 1);
 
101
  const shell = content.querySelector(`.reader-page[data-page="${page}"]`);
102
  if (shell) {
103
  await renderPdfShell(shell, false, true);
104
  shell.scrollIntoView({ block: "start" });
105
- if (restoredEntry && page === restoredEntry.page && restoredEntry.pageOffset) viewport.scrollTop += restoredEntry.pageOffset;
106
  }
107
  currentPage = page; pageInput.value = String(page); scheduleSave();
108
  }
@@ -178,13 +182,24 @@ async function renderPdf(prepared) {
178
  const visible = entries.filter((entry) => entry.isIntersecting).sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
179
  if (visible) { currentPage = Number(visible.target.dataset.page); pageInput.value = String(currentPage); scheduleSave(); }
180
  }, { root: document.querySelector("#viewport"), threshold: [0.2, 0.5, 0.8] });
181
- for (let page = 1; page <= pdf.numPages; page++) {
182
  const shell = document.createElement("section"); shell.className = "reader-page"; shell.dataset.page = String(page);
183
  shell.style.aspectRatio = `${firstViewport.width} / ${firstViewport.height}`;
184
  const canvas = document.createElement("canvas"); canvas.setAttribute("aria-label", `第 ${page} 页`); shell.appendChild(canvas);
185
- content.appendChild(shell); observer.observe(shell); pageObserver.observe(shell);
186
- }
187
- if (restoredEntry && restoredEntry.page) requestAnimationFrame(() => goToPage(restoredEntry.page));
 
 
 
 
 
 
 
 
 
 
 
188
  }
189
 
190
  async function renderPdfShell(shell, force = false, priority = false) {
@@ -263,7 +278,9 @@ function rerenderVisiblePdfPages() {
263
  }
264
  }
265
  async function renderText(markdown, prepared) {
266
- const response = await prepared.response; if (!response.ok) throw new Error(`HTTP ${response.status}`);
 
 
267
  const text = await response.text();
268
  if (!markdown) { const pre = document.createElement("pre"); pre.className = "reader-text"; pre.textContent = text; content.appendChild(pre); }
269
  else {
@@ -276,9 +293,14 @@ async function renderText(markdown, prepared) {
276
  async function renderEpub(prepared) {
277
  await prepared;
278
  const frame = document.createElement("div"); frame.className = "epub-frame"; content.appendChild(frame);
279
- const book = ePub(sourceUrl); epubRendition = book.renderTo(frame, { width: "100%", height: "100%", spread: "none", flow: "scrolled-doc" });
 
 
 
 
 
280
  epubRendition.on("relocated", (location) => { epubLocation = location && location.start ? location.start.cfi : ""; scheduleSave(); });
281
- await epubRendition.display(restoredEntry && restoredEntry.epubLocation || undefined); status.textContent = "EPUB";
282
  }
283
  async function start() {
284
  if (!validSource(sourceUrl) || capability.readerMode === VoiceOfMLReader.ReaderMode.UNSUPPORTED) return fail("此文件暂不支持在线阅读,请下载原文件。");
@@ -302,11 +324,11 @@ async function start() {
302
  } catch (error) { console.error(error); fail("原文件加载失败,请检查网络后重试,或下载原文件。"); }
303
  }
304
  function prepareDocument() {
305
- if (capability.mode === "pdf") return import(PDFJS_URL).then((pdfjs) => { pdfjs.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_URL; return pdfjs.getDocument({ url: sourceUrl, withCredentials: false }).promise; });
306
- if (capability.mode === "markdown") return Promise.all([fetch(sourceUrl), Promise.all([loadScript(MARKED_URL), loadScript(PURIFY_URL)])]).then(([response, engines]) => ({ response, engines }));
307
- if (capability.mode === "text") return fetch(sourceUrl).then((response) => ({ response }));
308
  if (capability.mode === "epub") return loadScript(EPUB_URL);
309
- if (capability.mode === "image") return new Promise((resolve, reject) => { const image = new Image(); image.className = "reader-image"; image.alt = title; image.decoding = "async"; image.onload = () => resolve(image); image.onerror = reject; image.src = sourceUrl; });
310
  return Promise.resolve(null);
311
  }
312
  start();
 
1
+ const PDFJS_URL = "/static/vendor/pdf.min.e0be3863c23c.mjs";
2
  const PDFJS_WORKER_URL = "/static/pdf-worker-wrapper.mjs";
3
+ const EPUB_URL = "/static/vendor/epub.min.06eae1574510.js";
4
+ const MARKED_URL = "/static/vendor/marked.min.eaccee2fb9fb.js";
5
+ const PURIFY_URL = "/static/vendor/purify.min.c2f26ea4fc0d.js";
6
  if (!Map.prototype.getOrInsertComputed) {
7
  Map.prototype.getOrInsertComputed = function(key, callback) {
8
  if (this.has(key)) return this.get(key);
 
25
  }
26
  const params = new URLSearchParams(location.search);
27
  const sourceUrl = params.get("url") || "";
28
+ const contentUrl = `/api/reader-content?url=${encodeURIComponent(sourceUrl)}`;
29
  const downloadUrl = params.get("download") || sourceUrl;
30
  const extension = (params.get("ext") || "").toLowerCase();
31
  const capability = VoiceOfMLReader.capability(extension);
 
53
  let pdfDocument = null;
54
  let pdfRenderGeneration = 0;
55
  let pdfActiveRenders = 0;
56
+ let pdfShellsReady = Promise.resolve();
57
+ let restorationApplied = false;
58
  const pdfRenderWaiters = [];
59
  let epubRendition = null;
60
  let epubLocation = "";
 
101
  async function goToPage(value) {
102
  if (!pageCount) return;
103
  const page = VoiceOfMLReader.clampNumber(value, 1, pageCount, 1);
104
+ await pdfShellsReady;
105
  const shell = content.querySelector(`.reader-page[data-page="${page}"]`);
106
  if (shell) {
107
  await renderPdfShell(shell, false, true);
108
  shell.scrollIntoView({ block: "start" });
109
+ if (!restorationApplied && restoredEntry && page === restoredEntry.page && restoredEntry.pageOffset) { viewport.scrollTop += restoredEntry.pageOffset; restorationApplied = true; }
110
  }
111
  currentPage = page; pageInput.value = String(page); scheduleSave();
112
  }
 
182
  const visible = entries.filter((entry) => entry.isIntersecting).sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
183
  if (visible) { currentPage = Number(visible.target.dataset.page); pageInput.value = String(currentPage); scheduleSave(); }
184
  }, { root: document.querySelector("#viewport"), threshold: [0.2, 0.5, 0.8] });
185
+ const createShell = (page) => {
186
  const shell = document.createElement("section"); shell.className = "reader-page"; shell.dataset.page = String(page);
187
  shell.style.aspectRatio = `${firstViewport.width} / ${firstViewport.height}`;
188
  const canvas = document.createElement("canvas"); canvas.setAttribute("aria-label", `第 ${page} 页`); shell.appendChild(canvas);
189
+ observer.observe(shell); pageObserver.observe(shell); return shell;
190
+ };
191
+ const firstShell = createShell(1); content.appendChild(firstShell);
192
+ await renderPdfShell(firstShell, false, true);
193
+ pdfShellsReady = (async () => {
194
+ for (let start = 2; start <= pdf.numPages; start += 24) {
195
+ const fragment = document.createDocumentFragment();
196
+ for (let page = start; page < Math.min(start + 24, pdf.numPages + 1); page++) fragment.appendChild(createShell(page));
197
+ content.appendChild(fragment);
198
+ await new Promise((resolve) => setTimeout(resolve, 0));
199
+ }
200
+ })();
201
+ await pdfShellsReady;
202
+ if (restoredEntry && restoredEntry.page) await goToPage(restoredEntry.page);
203
  }
204
 
205
  async function renderPdfShell(shell, force = false, priority = false) {
 
278
  }
279
  }
280
  async function renderText(markdown, prepared) {
281
+ let response = await prepared.response;
282
+ if (!response.ok) response = await fetch(sourceUrl);
283
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
284
  const text = await response.text();
285
  if (!markdown) { const pre = document.createElement("pre"); pre.className = "reader-text"; pre.textContent = text; content.appendChild(pre); }
286
  else {
 
293
  async function renderEpub(prepared) {
294
  await prepared;
295
  const frame = document.createElement("div"); frame.className = "epub-frame"; content.appendChild(frame);
296
+ try { await displayEpub(contentUrl, frame); }
297
+ catch (_) { frame.textContent = ""; await displayEpub(sourceUrl, frame); }
298
+ status.textContent = "EPUB";
299
+ }
300
+ async function displayEpub(url, frame) {
301
+ const book = ePub(url); epubRendition = book.renderTo(frame, { width: "100%", height: "100%", spread: "none", flow: "scrolled-doc" });
302
  epubRendition.on("relocated", (location) => { epubLocation = location && location.start ? location.start.cfi : ""; scheduleSave(); });
303
+ await epubRendition.display(restoredEntry && restoredEntry.epubLocation || undefined);
304
  }
305
  async function start() {
306
  if (!validSource(sourceUrl) || capability.readerMode === VoiceOfMLReader.ReaderMode.UNSUPPORTED) return fail("此文件暂不支持在线阅读,请下载原文件。");
 
324
  } catch (error) { console.error(error); fail("原文件加载失败,请检查网络后重试,或下载原文件。"); }
325
  }
326
  function prepareDocument() {
327
+ if (capability.mode === "pdf") return import(PDFJS_URL).then((pdfjs) => { pdfjs.GlobalWorkerOptions.workerSrc = PDFJS_WORKER_URL; return pdfjs.getDocument({ url: contentUrl, withCredentials: false }).promise.catch(() => pdfjs.getDocument({ url: sourceUrl, withCredentials: false }).promise); });
328
+ if (capability.mode === "markdown") return Promise.all([fetch(contentUrl), Promise.all([loadScript(MARKED_URL), loadScript(PURIFY_URL)])]).then(([response, engines]) => ({ response, engines }));
329
+ if (capability.mode === "text") return fetch(contentUrl).then((response) => ({ response }));
330
  if (capability.mode === "epub") return loadScript(EPUB_URL);
331
+ if (capability.mode === "image") return new Promise((resolve, reject) => { const image = new Image(); image.className = "reader-image"; image.alt = title; image.decoding = "async"; let fallback = false; image.onload = () => resolve(image); image.onerror = () => { if (!fallback) { fallback = true; image.src = sourceUrl; } else reject(new Error("image load failed")); }; image.src = contentUrl; });
332
  return Promise.resolve(null);
333
  }
334
  start();