Accelerate Reader content loading
Browse files- app.py +109 -6
- scripts/copy_reader_vendor.mjs +3 -0
- static/app.js +26 -0
- static/pdf-worker-wrapper.mjs +1 -1
- static/reader.html +3 -2
- static/reader.js +38 -16
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 394 |
current_url = validate_download_url(url)
|
|
|
|
|
|
|
| 395 |
for _ in range(MAX_REDIRECTS + 1):
|
| 396 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
current_url,
|
| 398 |
allow_redirects=False,
|
| 399 |
timeout=timeout,
|
| 400 |
-
|
| 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 |
-
|
| 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 |
-
|
| 186 |
-
}
|
| 187 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
epubRendition.on("relocated", (location) => { epubLocation = location && location.start ? location.start.cfi : ""; scheduleSave(); });
|
| 281 |
-
await epubRendition.display(restoredEntry && restoredEntry.epubLocation || undefined);
|
| 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(
|
| 307 |
-
if (capability.mode === "text") return fetch(
|
| 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 =
|
| 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();
|